goimports — Professional¶
1. Where the code lives¶
The goimports tool is a thin CLI wrapper around a library that does the real work.
- CLI entry point:
golang.org/x/tools/cmd/goimports(a singlemain.gothat wires flags and walks paths). - Core logic:
golang.org/x/tools/internal/imports(the import-fixup engine, candidate scanning, type-based filtering).
The same engine is embedded inside gopls (its organizeImports code action calls into the same internal/imports package), which is why the two are usually behaviorally consistent — but only when their versions agree. The CLI and gopls are released independently; mismatches between them are the most common source of "formatter says different things on different machines."
A practical implication: when you bump gopls in your editor, also bump the pinned goimports version in CI/Makefile so the two stay in lockstep.
2. The candidate-scanning algorithm¶
When goimports sees a reference to an unimported package foo, the engine roughly:
- Builds (or reuses a cached) dirCache of every package on disk: standard library, current module, every entry in
$(go env GOMODCACHE), every directory inGOPATH/src. Each entry keys on the directory name (the conventional package name). - Filters the dirCache to candidates whose name matches
foo. - For each candidate, parses just enough of the package to enumerate its exported identifiers — preferring export data from the build cache when available, falling back to source parsing.
- Picks the candidate whose exported set contains the needed symbol(s). Ties are broken by a fixed priority: standard library beats local module beats third-party; shorter paths beat longer.
- Synthesizes the corresponding
importspec and inserts it into the import block.
The expensive steps are 1 and 3. Step 1 happens once per process and is memoized in memory; that is why goimports -w over many files is roughly one scan plus N file rewrites, not N scans. Step 3 is bounded by candidate count and cache warmth — cold export data forces a full type-check of the candidate.
For unresolved symbols whose imports are already declared, goimports skips scanning entirely and just verifies the existing import remains referenced.
3. -srcdir: lying about where the file lives¶
When you run goimports on a file outside your module tree (e.g., processing a buffer in an editor, or running on a snippet), the import resolver does not know which module's go.mod to consult. -srcdir solves this:
The flag tells the engine "pretend this file lives in <srcdir> for import resolution purposes." Editors and language servers use it when they feed a file's in-memory buffer to the engine while the on-disk path is a temp file.
You will rarely set -srcdir yourself; you will see it in editor logs and in tools that wrap goimports programmatically.
4. goimports CLI vs gopls organizeImports¶
| Aspect | goimports CLI | gopls organizeImports |
|---|---|---|
| Trigger | Shell command, one-shot | LSP code action, in-editor |
| Reload cost | Builds candidate index each process start | Reuses live type info from the open session |
| Scope | One file (or many via path walk) | Single file in response to a request |
| Latency on warm cache | 100ms-1s per file | 5-50ms |
| Configuration | CLI flags (-local, -srcdir, …) | gopls settings (local, gofumpt) |
| CI usage | Standard | Not applicable (no LSP server) |
| Same library? | Yes, both call into golang.org/x/tools/internal/imports | Yes |
The takeaway: use gopls inside the editor, goimports CLI in CI. They share an engine so the results agree (subject to version pinning). Trying to run the CLI from inside the editor on every keystroke duplicates the candidate scan and is meaningfully slower.
5. Performance pitfalls in monorepos¶
In repos with hundreds of internal packages and thousands of vendored or cached third-party packages:
- Cold candidate index. First
goimportsinvocation aftergo clean -cachecan take seconds, not milliseconds. - Vendor explosion.
goimports -w .will descend intovendor/unless excluded; this rewrites thousands of files you do not own. Always pair with a path filter. - Generated files. Big generated files (protobuf, mocks) inflate
goimportstime per file dramatically because the AST is huge. Either exclude them or arrange for the generator to emit canonical output. - Symbol ambiguity at scale. In a large org you will have multiple internal packages named
client,util,model.goimports's name-based heuristic picks one; users are surprised. The-localflag does not change disambiguation, only grouping. The robust fix is to rename packages so names are unique, or to manually import the intended package in code templates. - CI feedback latency. Running
goimportson the full tree in CI is fine; running it serially in a single step that takes a minute is annoying. Parallelize withxargs -Por shard by directory.
find . -name '*.go' -not -path './vendor/*' -not -path '*/gen/*' -print0 | \
xargs -0 -P 8 -n 50 goimports -l -local example.com/myorg
6. Embedding goimports programmatically¶
The engine is importable:
import "golang.org/x/tools/imports"
opts := &imports.Options{
Fragment: false,
AllErrors: false,
Comments: true,
TabIndent: true,
TabWidth: 8,
FormatOnly: false, // false → also fix imports; true → just gofmt
}
out, err := imports.Process("path/to/file.go", srcBytes, opts)
Useful when building custom formatters, pre-commit tools, or editor plugins. Setting FormatOnly: true gives you gofmt-only behavior; setting it false is what the CLI does by default.
This is the same library gopls uses; if you wrap it, pin its version and update in lockstep with whatever ecosystem versions you also depend on.
7. Operational policy at scale¶
For a Go org maintaining tens of services:
- One formatter binary version, repo-wide. Pin in
tools/go.modor aMakefiletarget. Treat upgrades as PRs that touch every file affected by the version change. - One
-localstring. Lives inscripts/format.shand is the only place it is allowed to be defined. - CI gate uses
-l, never-w. Never let CI silently fix formatting; fail loudly so the diff lives in human-reviewed PRs. - Editor settings checked in. Ship a
.vscode/settings.json/.editorconfigso contributors do not need to configuregoimportsby hand. - Document the migration path to
gofumpt + gciif/when you outgrowgoimports. A single PR that switches the formatter, run on the entire tree at once, is the only sane way; piecemeal migration creates merge conflicts.
8. Summary¶
goimports is a small CLI over golang.org/x/tools/internal/imports; the same engine powers gopls's organizeImports. Its candidate-scanning algorithm scales with the size of your module graph and is memoized per process, which is why bulk runs are tolerable but per-file edits in an editor are best served by gopls instead. In production teams, pin the binary version, fix -local once, exclude vendor/ and generated trees, and parallelize CI runs with xargs -P — and plan ahead for the eventual migration to gofumpt + gci if your import grouping needs outgrow what -local can express.
Further reading¶
golang.org/x/tools/cmd/goimports: https://pkg.go.dev/golang.org/x/tools/cmd/goimportsgolang.org/x/tools/imports: https://pkg.go.dev/golang.org/x/tools/importsgoplsorganizeImportssource: https://github.com/golang/tools/tree/master/gopls/internal/golanggci: https://github.com/daixiang0/gcigofumpt: https://github.com/mvdan/gofumpt