gofmt vs goimports: Why Your CI Formatting Check Fails
TL;DR: gofmt only adjusts layout (indentation, alignment, spacing) and never touches imports; goimports = gofmt + automatic import addition/removal + import grouping. Running goimports in your editor but gofmt -l in CI (or the reverse) is the most common reason for "works on my machine, fails in CI". Standardize on goimports everywhere.
1. What gofmt does
gofmt is the official Go layout tool, installed with the toolchain, with a single job: make whitespace layout conform to one standard. It handles indentation (tabs), alignment, spacing around operators and brace placement. It never changes program semantics.
The flags that matter:
gofmt -l . # list non-conforming files without writing (this is your CI check)
gofmt -d main.go # show the diff it would apply, without writing
gofmt -w . # rewrite files in place
gofmt -s -w . # also simplify code (e.g. x[a:len(x)] -> x[a:])
gofmt -r 'rule' -w . # rewrite by pattern (rarely needed)
The boundary to remember: gofmt never adds or removes imports, and never reorders import groups. An imported-but-unused package is left in place by gofmt (the compiler will complain, but that is not gofmt's job).
2. The two extra things goimports does
goimports is not part of the standard toolchain; install it separately:
go install golang.org/x/tools/cmd/goimports@latest
It does everything gofmt does, then two things more:
- Adds and removes imports: use
fmt.Printlnwithout importingfmtand it adds the line; import something unused and it deletes it. - Groups and sorts imports: standard library in one group, third-party in another, alphabetical within each group.
A typical example. This file imports os without using it, and the groups
are a mess:
package main
import (
"github.com/gin-gonic/gin"
"fmt"
"os"
)
func main() {
fmt.Println(gin.Version)
}
gofmt output: only sorts "fmt" alphabetically inside the same group;
os stays:
import (
"fmt"
"github.com/gin-gonic/gin"
"os"
)
goimports output: drops the unused os and splits stdlib from
third-party:
import (
"fmt"
"github.com/gin-gonic/gin"
)
Same input, different output — that is the root cause of "the format check passes locally but fails in CI".
3. Three scenarios where the output diverges
a. An unused import exists
gofmt keeps it, goimports removes it. If your editor runs goimports on save but CI checks with gofmt -l, everything passes; the reverse combination — gofmt locally, goimports -l in CI — breaks the build.
b. A missing import
goimports tries to add it automatically. When several candidates exist (is
rand math/rand or math/rand/v2?), it picks one from
its index — and a wrong guess is a compile error. Always eyeball what goimports added
before committing.
c. Your own organization's packages land in the wrong group
By default goimports lumps every non-stdlib path into the third-party group, so
git.company.com/team/utils ends up mixed with GitHub dependencies. Use
-local to give your own prefix its own group:
goimports -local git.company.com -w .
Mirror the setting in your editor (VS Code's go.formatTool and the
local setting of gopls), or editor formatting will disagree with the CLI.
4. The correct CI setup
One principle: the editor, the command line and CI must use the same tool with the same flags. Standardizing on goimports is recommended (it is a superset of gofmt and strictly stronger):
# any output means some file is non-conforming; exit 1
files=$(goimports -local git.company.com -l .)
if [ -n "$files" ]; then
echo "files failing goimports:"
echo "$files"
exit 1
fi
A complete GitHub Actions example:
name: check
on: [push, pull_request]
jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod # stay in sync with the repo, kills version skew
- run: go install golang.org/x/tools/cmd/goimports@latest
- run: |
files=$(goimports -l .)
test -z "$files" || { echo "need goimports: $files"; exit 1; }
- run: go vet ./...
- run: go test ./...
Version pitfall: gofmt shipped with different Go versions can differ on edge-case
syntax, and goimports grouping behavior has changed across releases. Pinning CI with
go-version-file: go.mod eliminates the whole class of "I formatted it locally,
why does CI still fail".
5. Going stricter: gofumpt and staticcheck
- gofumpt: a stricter superset of gofmt (e.g. enforcing multi-line import blocks). Pick exactly one per repo — mixing gofmt and gofumpt produces flip-flopping diffs.
- staticcheck: not a formatter but a deep static analyzer, usually run alongside the formatting check in CI:
go install mvdan.cc/gofumpt@latest
gofumpt -l .
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...
6. Summary
| gofmt | goimports | |
|---|---|---|
| Installation | Ships with Go | go install golang.org/x/tools/cmd/goimports@latest |
| Code layout | Yes | Yes (identical) |
| Add/remove imports | No | Yes |
| Import grouping | No | Yes (-local for your own prefix) |
| CI check | gofmt -l . | goimports -l . (recommended) |
For more commands and pitfalls (gofmt's -s/-r, the goimports indexing mechanism), see the Go toolchain command cheat sheet, section "Formatting & Code Tools".