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:

  1. Adds and removes imports: use fmt.Println without importing fmt and it adds the line; import something unused and it deletes it.
  2. 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

gofmtgoimports
InstallationShips with Gogo install golang.org/x/tools/cmd/goimports@latest
Code layoutYesYes (identical)
Add/remove importsNoYes
Import groupingNoYes (-local for your own prefix)
CI checkgofmt -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".

Further reading

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also: Browse existing issues · Email feedback (no GitHub account needed): 278975598@qq.com