skip to content

Why does a CI step running `gofmt -l .` pass even when files are unformatted, and how do you make it fail?

level: juniorimportance: must knowfreq 55%

answer

  1. listing something is not failing on it
  2. which stream carries the complaint?
  3. -l prints names and still returns 0
  4. wrap it so empty output means pass

basics

~20 s

gofmt -l lists the paths of files whose formatting differs, then exits 0 whether that list is empty or not, so the step passes. Make the build fail on non-empty output instead, wrapping it with test -z.

solid answer

~50 s

`gofmt -l` is a reporting flag: it prints one path per file whose formatting differs from gofmt's output and then exits 0. A non-zero status only comes from a real error such as a parse failure or an unreadable file, so a shell step that just runs `gofmt -l .` reports success while printing the very filenames you wanted it to reject. You have to turn the output into a status yourself: `test -z "$(gofmt -l .)"` fails when anything was listed, and `gofmt -l -d .` additionally prints the diff so the job log shows what to change. The other common wiring is to let the formatter write — `go fmt ./...`, which is a wrapper around `gofmt -l -w` — and then run `git diff --exit-code`, which exits 1 if the tree changed.

code

text · 7 lines
text
$ gofmt -l .
internal/store/db.go
$ echo $?
0
$ test -z "$(gofmt -l .)"
$ echo $?
1

go deeper

for a junior

Remember that gofmt -l prints filenames and returns success. Be ready to write the one-line wrapper that fails the step, and to say why gofmt -w does not belong in a check.

for a middle

Explain the mechanics: which stream carries the complaint, when gofmt genuinely exits non-zero, and how the go fmt plus git diff --exit-code variant produces the same signal a different way.

for a senior

Show the production judgment: put the check in one script both CI and laptops run, print the diff rather than a filename, and scope the file list so vendored and generated trees do not drown the report.

for a principal

Frame it as a gate policy question. A formatting gate is worth blocking merges on precisely because it is deterministic and mechanically fixable, and its cost is bounded only if every engineer can reproduce it in one command.

## What `gofmt -l` actually does `gofmt` is the Go formatter. Given a file, it prints the formatted source to standard output; given `-w` it rewrites the file in place; given `-d` it prints a unified diff; given `-l` it prints nothing but **the name of each file whose contents differ from its formatted form**. Given a directory, it walks that directory tree and processes every `.go` file it finds. The crucial detail for build wiring is the **exit status**. `gofmt` treats "this file is not formatted" as information, not as an error. The process exits 0. A non-zero exit status means something went wrong for gofmt itself: a file it could not parse, a file it could not read, a bad flag. So this CI step is a no-op: ``` gofmt -l . ``` It faithfully prints `internal/store/db.go`, the shell records status 0, the step is green, and the unformatted file merges. ## Turning output into a status A check step must convert "produced output" into "failed". The idiomatic wrappings: - `test -z "$(gofmt -l .)"` — the command substitution captures the listing; `test -z` succeeds only on the empty string, so a single listed file fails the step. Its weakness is that the listing is swallowed, so add `gofmt -l -d .` or echo the captured value before failing. - `gofmt -l -d .` combined with the `test -z` form, or a two-line script that stores the listing in a variable, prints it, and exits 1 if it is non-empty. This is what you want in a job log: the reader sees the diff, not just a red cross. - Run the formatter for real and diff the tree: `go fmt ./...` (a thin wrapper that runs `gofmt -l -w` over the packages named by the pattern) followed by `git diff --exit-code`. `git diff --exit-code` exits 1 when the working tree differs from the index, so the rewrite becomes the failure signal. This is the same shape used for generated code and it has the nice property that the diff in the log *is* the fix. What you must not do is put `gofmt -w` alone in a build. It silently repairs the tree inside the job, the job passes, and the repository stays unformatted forever. ## What each command looks at `gofmt` is a **filesystem** tool. It knows nothing about modules, packages, or build constraints; it walks directories and formats `.go` files. That means `gofmt -l .` descends into `vendor/`, into `testdata/`, and into any generated output committed under the repo root, and it will list files you never wrote. The usual fix is to feed it an explicit file list from version control, such as the tracked `.go` files, rather than a directory. The `go` command's `./...` pattern is a **package** pattern and behaves differently: it expands to the packages of the current module, skipping `vendor/`, directories named `testdata`, and directories whose names begin with `.` or `_`. That is why `go vet ./...` and `gofmt -l .` can disagree about which files are in scope, and why a repo with a large `vendor/` directory often has a format step that mysteriously reports thousands of files. ## Contrast with `go vet` The reason the gofmt gotcha surprises people is that the neighbouring check behaves the way they expected. `go vet ./...` exits non-zero when it reports a diagnostic, so `go vet ./...` on its own line is a perfectly good failing step with no shell wrapping at all. Two tools, two conventions: one reports through stdout, one reports through the exit status. When you write the repository's check script, verify the status of each tool you invoke rather than assuming. ## Keeping the check honest Whatever wrapping you choose, put it in a single script or task that a developer can run locally with one command, and have CI invoke exactly that script. A format gate whose incantation lives only in the pipeline definition is a gate people cannot reproduce before pushing, and every failure then costs a full CI round trip. The check is cheap — formatting is deterministic and the fix is mechanical — so the only thing that makes it expensive is not being able to run it where the code is written.

  • Which files does `gofmt -l .` look at compared with `go vet ./...`?
    `gofmt` is a filesystem tool: it walks the directory and formats every `.go` file, including ones under `vendor/` and `testdata/`. `./...` is a package pattern expanded by the go command, and it skips `vendor`, `testdata`, and directories beginning with `.` or `_`, and it needs the packages to type-check. That is why the two steps can disagree about scope, and why format steps are often fed an explicit list of tracked files instead of a directory.
  • Is `go fmt ./...` a safe command to put in a check step?
    Not on its own. `go fmt` is a wrapper that runs `gofmt -l -w` over the named packages, so it rewrites the files in place and exits 0; the job goes green and the repository is still unformatted at the next checkout. It is usable only when followed by `git diff --exit-code`, which fails when the rewrite changed anything and prints the change into the log.
  • Why prefer `gofmt -d` over plain `gofmt -l` in a failing job?
    `-l` gives a filename, which forces the author to reproduce the problem locally to see it. `-d` prints the unified diff, so the log shows exactly what the formatter would change and the fix is obvious from the failure alone. It does not change the exit-status behaviour, so you still need the same wrapping to fail the step.

It is a smoke detector wired to a light bulb instead of a siren: it notices perfectly well, it just does not stop anyone.

saying these in an interview costs you the question

  • Says gofmt -l exits non-zero when it lists a file
  • Puts gofmt -w in the build and calls the step a check
  • Assumes go fmt ./... only reports and never edits files
  • Greps gofmt output for the word error rather than for any output
  • Believes gofmt skips vendor because the go command does