Package Import Rules — Find the Bug¶
Each snippet contains a real-world bug related to Go's package import rules. Find it, explain it, fix it.
Bug 1 — Cyclic import between two packages¶
// file: pkg/order/order.go
package order
import "github.com/me/shop/pkg/customer"
type Order struct {
Buyer customer.Customer
Items []string
}
// file: pkg/customer/customer.go
package customer
import "github.com/me/shop/pkg/order"
type Customer struct {
Name string
History []order.Order
}
$ go build ./...
package github.com/me/shop/pkg/order
imports github.com/me/shop/pkg/customer
imports github.com/me/shop/pkg/order: import cycle not allowed
Bug: order imports customer to embed Customer, and customer imports order to keep a slice of past orders. The compiler refuses any A→B→A cycle, full stop. Go has no forward declarations and no header files; cycles are a design smell, not just a syntactic one.
Fix: break the cycle by extracting a third package or by depending on an interface instead of a concrete type. The cleanest move: put the shared "order history" type in its own package, or invert the relationship so only one side knows the other:
// file: pkg/order/order.go
package order
import "github.com/me/shop/pkg/customer"
type Order struct {
Buyer customer.Customer
Items []string
}
The history of past orders for a buyer becomes a query (order.HistoryFor(custID)), not a field on Customer. No cycle.
Bug 2 — Importing an unexported identifier¶
// file: cmd/server/main.go
package main
import "github.com/me/app/pkg/auth"
func main() {
h := auth.hashPassword("hunter2")
_ = h
}
Bug: Identifiers that start with a lowercase letter are package-private. They can only be referenced from inside the same package. There is no friend, no protected, no internal keyword — capitalisation is the visibility modifier.
Fix: if hashPassword is genuinely meant to be the public API, rename it. If it should stay private, expose a wrapper that does the right thing:
// file: pkg/auth/auth.go
package auth
func HashPassword(p string) string { return hashPassword(p) }
func hashPassword(p string) string { /* ... */ return "" }
Now auth.HashPassword(...) works from outside the package.
Bug 3 — The same path imported twice¶
Bug: A path must appear at most once in an import block (unless one of the duplicates uses an alias). A copy-paste merge or a hand-edited diff often produces this.
Fix: delete the duplicate. If you really need two views of the same package, alias one:
But there is almost never a reason to do that — the dedup is the right call.
Bug 4 — Import alias collides with a local variable¶
package main
import (
"fmt"
log "log/slog"
)
func main() {
log := "starting up"
fmt.Println(log)
log.Info("started") // ???
}
Bug: The local variable log := "starting up" shadows the imported package alias log. Inside main, the identifier log now resolves to a string, not the slog package.
Fix: rename either the alias or the variable. Idiomatic Go uses the package name as the alias:
import (
"fmt"
"log/slog"
)
func main() {
msg := "starting up"
fmt.Println(msg)
slog.Info("started")
}
If you really want the short name log, pick a different identifier for the message.
Bug 5 — Blank import that no longer registers anything¶
package main
import (
"database/sql"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", "...")
_, _ = db, err
}
A teammate later cleans up github.com/lib/pq's init() (or upgrades to a major version that drops the side-effect):
// in github.com/lib/pq v2 (hypothetical)
package pq
// init() removed; users must call pq.Register() explicitly now
Bug: A blank import (_ "path") only runs the imported package's init() functions. If those side effects are removed (or never existed), the blank import becomes silently useless. The database/sql driver registry stays empty and sql.Open("postgres", ...) returns "unknown driver".
Fix: prefer explicit registration when a library supports it, and write a smoke test that asserts the driver is present:
func TestDriverRegistered(t *testing.T) {
found := false
for _, name := range sql.Drivers() {
if name == "postgres" { found = true; break }
}
if !found { t.Fatal("postgres driver not registered") }
}
Bug 6 — Dot import shadows a stdlib name¶
package main
import (
"fmt"
. "github.com/me/app/pkg/strings" // local helper package
)
func main() {
fmt.Println(ToUpper("hello"))
}
// file: pkg/strings/strings.go
package strings
func ToUpper(s string) string { return "<<UPPERCASE>>" + s }
Later, somebody adds:
import (
"fmt"
"strings"
. "github.com/me/app/pkg/strings"
)
func main() {
fmt.Println(strings.ToUpper("hello"))
}
Bug: Dot imports (.) merge another package's exported names into the current scope. The moment any other identifier — including a stdlib import — collides, the file refuses to compile. Even when there is no syntactic clash, dot imports make every unqualified call ambiguous to the reader.
Fix: avoid dot imports outside of test DSLs (Ginkgo, gomega). Use a normal import or a short alias:
import (
"fmt"
"strings"
mystr "github.com/me/app/pkg/strings"
)
func main() {
fmt.Println(mystr.ToUpper("hello"))
}
Bug 7 — Crossing an internal/ boundary from another module¶
// in module github.com/acme/api
// file: internal/secret/secret.go
package secret
const APIKey = "sk-live-..."
// in module github.com/me/myapp
// file: main.go
package main
import "github.com/acme/api/internal/secret"
func main() { _ = secret.APIKey }
Bug: Any path containing internal/ is only importable by packages rooted in the parent of that internal/ directory and within the same module. From a different module, the import is forbidden — by design.
Fix: if the symbol is meant to be public, move it out of internal/. If it is genuinely private, you cannot import it; either fork the upstream module, ask the maintainer for a public API, or copy the value:
// upstream — promote to public
// file: secret/secret.go (no longer under internal/)
package secret
const APIKey = "..."
Then consumers can import "github.com/acme/api/secret".
Bug 8 — Underscore in folder name vs the actual package name¶
// file: pkg/http_utils/utils.go
package http_utils
func Join(a, b string) string { return a + "/" + b }
// file: cmd/server/main.go
package main
import "github.com/me/app/pkg/http_utils"
func main() { _ = httputils.Join("a", "b") }
Bug: The folder name contains an underscore, so the import path contains an underscore too. The author then wrote httputils.Join(...) (no underscore) assuming the underscore was just a folder convention. It is not — the package name in source is what determines the qualifier, and it is http_utils.
Worse: go vet and reviewers all complain about the underscore. Go style guide forbids underscores in package names.
Fix: rename the folder and the package clause to a single word:
Bug 9 — Wrong-cased import path on a case-sensitive filesystem¶
Locally on macOS:
In CI on Linux:
Bug: macOS uses a case-insensitive filesystem by default, so both Sirupsen and sirupsen resolve to the same directory in the module cache. Linux is case-sensitive — only the canonical lowercase path works. The repo was renamed years ago; the old casing only "works" on Mac.
Fix: rewrite the import to the canonical path and run go mod tidy:
Add a CI step on Linux to catch this class of bug early.
Bug 10 — Ambiguous import across two majors¶
A reviewer simplifies things:
Bug: github.com/foo/bar and github.com/foo/bar/v2 are different modules. Importing the v1 path gives you v1 types, even if v2 is in go.mod. Consumers often confuse "I have v2 in my go.mod" with "all my code uses v2".
Fix: decide on a single major across the project. If you need both for migration, use distinct aliases consistently, and write tests that assert which methods you call. Otherwise, drop one major and delete the unused require:
Bug 11 — Build tag mismatch hides an import¶
// file: pkg/sysinfo/sysinfo_linux.go
//go:build linux
package sysinfo
import "syscall"
func PageSize() int { return syscall.Getpagesize() }
A teammate adds Darwin support but mistypes the tag:
// file: pkg/sysinfo/sysinfo_darwin.go
//go:build darvin
package sysinfo
import "syscall"
func PageSize() int { return syscall.Getpagesize() }
$ GOOS=darwin go build ./...
pkg/sysinfo/sysinfo.go:3:6: PageSize redeclared in this block
pkg/sysinfo/sysinfo_darwin.go:5:6: other declaration of PageSize
Or, on Linux:
Bug: darvin is a typo for darwin. The build constraint never matches, so the file is included unconditionally, colliding with the fallback. (Or, depending on the _GOOS filename suffix, the file is filtered out and the import vanishes.)
Fix: use go vet and check tags carefully. Constraints must be valid GOOS/GOARCH tokens or whitelisted values:
CI should run both GOOS=linux and GOOS=darwin builds for code that uses tags.
Bug 12 — Missing package clause produces a confusing error¶
Bug: The very first non-comment, non-blank line of every Go source file must be package <name>. Forgetting it gives a parser error that points at the import line, which mislead a junior engineer into "deleting" the import — and breaking the file further.
Fix: add the package clause:
A linter (gofmt, goimports, or golangci-lint with unused) will not even run on a file without package — fix that first, always.
Bug 13 — package foo_test cannot reach unexported fields¶
// file: pkg/cache/cache.go
package cache
type Cache struct {
items map[string]string
}
func New() *Cache { return &Cache{items: map[string]string{}} }
func (c *Cache) Set(k, v string) { c.items[k] = v }
// file: pkg/cache/cache_test.go
package cache_test
import (
"testing"
"github.com/me/app/pkg/cache"
)
func TestSet(t *testing.T) {
c := cache.New()
c.Set("k", "v")
if c.items["k"] != "v" { // !!
t.Fatal("not set")
}
}
$ go test ./pkg/cache
./cache_test.go:13:8: c.items undefined (cannot refer to unexported field
or method items of struct cache.Cache)
Bug: A file with package foo_test is in a different package (the "external test" pattern). It only sees the exported API. Reaching into c.items is a clue that the test wants to be in package foo (white-box).
Fix: decide intentionally. White-box test → use package cache:
Or keep package cache_test and test through the public API:
func TestSet(t *testing.T) {
c := cache.New()
c.Set("k", "v")
if got := c.Get("k"); got != "v" { t.Fatalf("got %q", got) }
}
(Black-box tests are usually preferable; they keep the public API honest.)
Bug 14 — Init order broken by file rename¶
// file: pkg/conf/a_defaults.go
package conf
var Settings = map[string]string{}
func init() { Settings["env"] = "dev" }
// file: pkg/conf/b_overrides.go
package conf
func init() { Settings["env"] = "prod" } // overrides a_defaults
A teammate renames b_overrides.go → 0_overrides.go to "make it sort first":
Bug: Within a single package, init() functions run in the order the compiler processes files, which is alphabetical by filename. The author renamed b_overrides.go (runs after a_defaults.go) to 0_overrides.go (runs before it). Now defaults overwrite overrides.
Fix: never rely on alphabetical filename order for init() correctness. It is a brittle contract. Replace it with explicit code:
// file: pkg/conf/conf.go
package conf
var Settings = map[string]string{
"env": "dev",
}
func init() {
if os.Getenv("ENV") == "prod" {
Settings["env"] = "prod"
}
}
If you truly need ordered initialisation, do it from a single init() that calls helpers in the right order.
Bug 15 — Side-effect import placed too late¶
package main
import (
"database/sql"
"fmt"
"log"
)
func main() {
db, err := sql.Open("postgres", "...")
if err != nil { log.Fatal(err) }
fmt.Println(db.Ping())
}
import _ "github.com/lib/pq" // mistakenly added at bottom
Bug: All imports must be in a single import block (or sequence of import declarations) at the top of the file, after package and before any other declaration. Some IDE quick-fixes append imports at the bottom — Go rejects this outright.
Fix: move the side-effect import into the main import block:
gofmt/goimports will fix this automatically; run them on save.
Bug 16 — Vendored deps but -mod=mod set¶
$ ls
go.mod go.sum vendor/ cmd/ ...
$ go env GOFLAGS
-mod=mod
$ go build ./...
go: finding module for package github.com/spf13/cobra
go: downloading github.com/spf13/cobra v1.8.0
... (downloading from network instead of using vendor/)
Bug: When a vendor/ directory exists, the default mode is -mod=vendor. Someone set GOFLAGS=-mod=mod globally, which bypasses the vendor tree and re-fetches everything from the proxy. CI builds become non-deterministic; air-gapped builds fail entirely.
Fix: unset the flag (or set it to vendor explicitly):
Verify:
Document the build mode in Makefile:
Bug 17 — Package name in source ≠ folder name¶
// file: cmd/server/main.go
package main
import "github.com/me/app/pkg/util"
func main() { fmt.Println(util.Hello()) }
Bug: The import path is .../pkg/util, but inside the file the package clause says helpers. The default qualifier in consumers is the package name, not the last path segment. So util.Hello() is wrong; it would have to be helpers.Hello().
This is legal Go but a giant footgun. Tools warn about it (go vet, golangci-lint's revive rule).
Fix: keep the package name and folder name aligned. Either rename the package:
Or rename the folder:
If you really must diverge (e.g. package main in a cmd/server/ folder), explicitly alias on import — but for libraries, just match the names.
Bug 18 — Conditionally compiled-out import¶
// file: net.go
//go:build linux
package net
import "github.com/vishvananda/netlink"
func List() ([]string, error) {
links, err := netlink.LinkList()
_ = links
return nil, err
}
// file: net_other.go
//go:build !linux
package net
func List() ([]string, error) { return nil, nil }
A teammate, on macOS:
Then on Linux:
Bug: go mod tidy only inspects packages reachable under the current GOOS/GOARCH. On macOS, net.go is excluded by the build tag; tidy thinks netlink is unused and removes it. The Linux build then fails because the require is gone.
Fix: run go mod tidy against the union of supported platforms. Modern Go has a flag for this:
Better yet, run tidy in CI with GOOS=linux and GOOS=darwin, and fail if go.mod would change:
Or use the official solution: declare an //go:build ignore file that imports every conditional dep so tidy keeps them.
Bug 19 — Indirect cycle through a type alias¶
// file: pkg/b/b.go
package b
import "github.com/me/app/pkg/a"
type Handler struct {
Next a.Handler // uses the alias
}
$ go build ./...
package github.com/me/app/pkg/a
imports github.com/me/app/pkg/b
imports github.com/me/app/pkg/a: import cycle not allowed
Bug: Type aliases (type X = Y) do not weaken the import graph. The compiler still has to resolve b.Handler while compiling a, and a.Handler while compiling b. The cycle is real even though it looks like "just" an alias.
Fix: put the shared type in a third package that both depend on:
// file: pkg/b/b.go
package b
import "github.com/me/app/pkg/handler"
type Wrapper struct { Next handler.Handler }
a and b no longer reference each other.
Bug 20 — goimports removes a build-tag-only import¶
// file: net_linux.go
//go:build linux
package main
import (
"fmt"
"github.com/vishvananda/netlink"
)
func showLinks() { fmt.Println(netlink.LinkList()) }
A teammate on macOS hits "format on save" — their editor runs goimports. Because netlink is not used in any file visible under GOOS=darwin, goimports rewrites the imports to remove it… but only on this file, which still references netlink. The next pull request:
Bug: goimports evaluates imports against the current build tags. When run on macOS, it does not see net_linux.go as part of the build and over-eagerly drops the import — even though it appears literally in the file.
Fix: do not rely on goimports to manage imports inside files with build tags; pin imports manually, or ensure your editor invokes goimports with the right GOOS. Most teams configure CI to run goimports -l . against every supported platform and fail if the diff is non-empty:
The IDE-level fix: tell your editor to set GOFLAGS=-tags=linux (or both) when running formatters on these files.
Bug 21 — Blank import grouped wrong, formatter rearranges it¶
After gofmt/goimports:
Or worse, in some setups:
Bug: Blank imports (_ "path") and forced imports look like normal imports to gofmt. They get sorted alphabetically and grouped with everything else. The intent — "this import has side effects, treat it specially" — is lost; readers see it mixed in with regular packages and may delete it during cleanup.
Fix: put blank imports in their own group, separated by a blank line. gofmt respects existing groups; once they exist, it will not merge them:
Add a comment explaining why the blank import exists. Future-you (and CI) will not delete it.
Bug 22 — replace to a folder whose go.mod has a different module name¶
// file: go.mod
module github.com/me/app
go 1.22
require github.com/me/shared v1.0.0
replace github.com/me/shared => ../shared
$ go build ./...
go: github.com/me/app: github.com/me/shared@v0.0.0-00010101000000-000000000000
(replaced by ../shared): replacement module has different module path
"github.com/me/shared-internal" but "github.com/me/shared" is required
Bug: When you replace a module path with a local directory, the target's go.mod must declare the same module path you are replacing. Here replace github.com/me/shared => ../shared points at a directory whose go.mod says github.com/me/shared-internal — so Go refuses.
Fix: either rename the target's go.mod:
Or change the replace to use the actual module name:
Use go.work for multi-module local development; it sidesteps replace entirely and tracks paths cleanly:
Summary¶
Most package-import bugs come from one of these mistakes:
- Treating package boundaries casually. Cycles (Bug 1, 19),
internal/(Bug 7), and exported/unexported identifiers (Bug 2, 13) are not advisory — the compiler enforces them. - Diverging the package name from the folder name. Folder,
packageclause, and import alias should agree. Bugs 8, 17 stem from misalignment. - Trusting tooling on a single platform.
goimports(Bug 20),go mod tidy(Bug 18), and editor format-on-save can silently break cross-platform builds. CI must run on every supportedGOOS. - Letting side-effects slip. Blank imports (Bug 5, 15, 21),
init()order (Bug 14), and missing comments make code that "just works" until somebody innocently rearranges it. - Mixing local and published state.
replacewith mismatched names (Bug 22),vendorvs-mod=mod(Bug 16), and casing on case-insensitive filesystems (Bug 9) appear only when somebody else clones the repo.
Fix the rules at the toolchain layer — go vet, gofmt, golangci-lint, multi-platform CI — and most of this list never reaches a code review again.