1. Quality pass
If you look at a project through a beginner’s eyes, it’s easy to think: “The code compiles — so it works.” That’s cute, but it’s roughly like calling a car “fine” because the door closes. In Go, the culture is different: historically, the language was designed so that tools are part of day-to-day development, not something exotic for “senior engineers in an ivory tower.” That’s why, alongside the compiler and tests, the ecosystem has always had gofmt and the go command, which make development predictable and automatable.
By a “quality pass” (quality gate) we’ll mean the minimal set of steps after which you can honestly say: “OK, at least this change isn’t embarrassing to show the team.” Today we’ll lock in this exact order:
format (gofmt/goimports) → tests (go test) → static analysis (go vet) → lint
And an important idea: this is not a “magic sequence,” it’s a way to quickly get useful signal and avoid drowning in noise.
2. Why the fmt → test → vet → lint order matters more than it seems
There’s a common pain point: you run a linter, it spits out 60 findings, you start fixing style, and then suddenly you realize tests don’t pass at all, and somewhere fmt.Printf prints a string using %d. That’s the case where you’re painting the walls while the kitchen is on fire.
The order is needed for the “remove noise first, then catch bugs” strategy. Formatting and imports stabilize the code so Git diffs are meaningful and so the next tools don’t complain about trivia. Then tests validate behavior. After that, vet catches what the compiler usually lets through (often these really are potential defects). And only then do linters bring the project in line with maintainability agreements.
An easy way to remember: gofmt makes code readable, go test makes it verifiable, go vet makes it less sneaky, and linters make it less likely to “drift apart” over time.
Let’s draw it as a mini diagram:
flowchart LR A[fmt: gofmt/goimports] --> B[test: go test ./...] B --> C[vet: go vet ./...] C --> D[lint: staticcheck/golangci-lint] D --> E[ready for review]
3. Step 1: formatting (gofmt, goimports)
Formatting in Go is not “personal preference,” it’s an ecosystem-level agreement. In the “spaces vs tabs” debate, gofmt wins — and honestly, it’s a rare case where life gets easier for everyone. Go’s tools are historically strong precisely because the language and the standard library support the tooling approach well.
In our learning app (let’s call it tasker) there’s a task package with task logic, and cmd/tasker is the CLI entry point. Imagine you hacked some code together on emotion and it looks like this:
package task
import "strings"
func NormalizeTitle(s string) string {return strings.TrimSpace(s)}
Does it compile? Yes. Is it pleasant to read? Well… only if you like code that looks like a message from a cat that walked across the keyboard.
A normal formatting pass usually looks like this (run from the project root):
gofmt -w .
goimports -w .
The reason to split them is simple: gofmt brings code to the standard shape, and goimports additionally fixes imports (removes unused ones, adds missing ones, sorts them). After that your code becomes this:
package task
import "strings"
func NormalizeTitle(s string) string {
return strings.TrimSpace(s)
}
And an important psychological effect: after this, code review is about meaning, not about “why is that curly brace lonely in this life.”
4. Step 2: tests (go test) and calmly reading the output
Tests are your way to keep confidence that the program’s behavior hasn’t broken. In Go this is especially important, because the language and its evolution rely heavily on testing as the main tool for detecting incompatibilities and unexpected breakages.
We’ll run tests across the entire project:
go test ./...
./... means “the current directory and all subpackages recursively” — it’s the most common mode for a project.
Mini example from our tasker
Suppose we have a function that builds a line to print a task in a list:
package task
type Task struct {
Title string
Done bool
}
func Line(t Task) string {
if t.Done {
return "[x] " + t.Title
}
return "[ ] " + t.Title
}
And a test:
package task
import "testing"
func TestLine(t *testing.T) {
if got := Line(Task{Title: "buy milk", Done: false}); got != "[ ] buy milk" {
t.Fatalf("got %q", got)
}
}
If tests pass, you’ll see something like:
ok example.com/tasker/task 0.003s
ok example.com/tasker/cmd/tasker 0.002s
If a test fails, go test usually shows the key things: which test, where it failed, and your message. For example, you accidentally broke the space after ]:
func Line(t Task) string {
if t.Done {
return "[x]" + t.Title // bug: missing space
}
return "[ ]" + t.Title // bug: missing space
}
Then the output will look like:
--- FAIL: TestLine (0.00s)
task_test.go:7: got "[ ]buy milk"
FAIL
FAIL example.com/tasker/task 0.003s
Read it in this order: first the line --- FAIL: ..., then file and line (task_test.go:7), then your message (got ...). The rest is “boilerplate.”
Important note about Example tests
We discussed earlier that in Go, examples in documentation can be executable and verifiable: Example... functions in _test.go with a // Output: comment are actually run by go test, and that helps keep documentation honest. In today’s gate that’s also a plus: there’s less chance an example gets stale and starts lying.
5. Step 3: go vet and suspicious spots in code
After tests, it’s time for go vet. It’s a static analyzer that looks for suspicious constructs. The key word here is “suspicious”: vet doesn’t guarantee it found a real bug, but its signals are usually worth attention, because they’re about potential defects rather than style.
Running it is straightforward:
go vet ./...
Classic example: Printf and format mismatches
In cmd/tasker/main.go we decided to print the number of tasks:
package main
import "fmt"
func main() {
title := "42"
fmt.Printf("%d\n", title) // error: %d expects a number, but we passed a string
}
The compiler may let this through because Printf takes ...any. But go vet will usually complain with something like this (the wording may differ, but the meaning is the same):
cmd/tasker/main.go:8:2: fmt.Printf format %d has arg title of wrong type string
How to fix it: either change the placeholder to %s, or convert the string to a number (if it really is a number), but conversion is separate logic with error handling, and doing it “just to silence vet” is a bad idea.
How to treat vet messages
The most useful habit: treat vet as a “high-risk” signal. Not always a “blocker,” but often closer to a bug than to bikeshedding. If vet complains, your first question should not be “how do I turn it off,” but “why is this suspicious, and what am I doing wrong.”
6. Step 4: linters and reading tool output
Linters are not a single tool, but a whole class. In Go you’ll often see staticcheck or the golangci-lint aggregator (which runs a set of analyzers). We won’t dive into the “linter zoo,” but we’ll lock in their role in the quality gate: linters help keep a project maintainable, highlight problematic patterns, and make code less prone to “slow decay.”
How you run them depends on what the project uses. For example:
staticcheck ./...
or:
golangci-lint run
Mini example: error text style as a maintainability signal
Suppose in the task package you introduced an error:
package task
import "errors"
var ErrBadInput = errors.New("Bad input.")
From the compiler’s perspective everything is fine. But many linters will highlight that in Go, error messages are conventionally written in lowercase, without a period, without “Error:”. That’s not because “an ancient gopher decreed it,” but because errors often get wrapped with context, and you want the final message chain to read like one sentence.
A normal version:
var ErrBadInput = errors.New("bad input")
It’s important to prioritize correctly: if tests are red, tests first. If vet found a Printf error, that comes first. Linters almost always come after.
How to read tool output: “where,” “what,” “how urgent”
When you first see output from go test/vet/a linter, it can feel like a stream of symbols. But if you break it down into a template, it becomes calmer. Almost always, a message consists of three parts: location (file:line:column), diagnosis (what’s wrong), and sometimes a recommendation/rule code.
Here’s a handy “how to interpret” table (not a law of nature, but a good practical model):
| Tool | Signal type | Usually this is… | What we do first |
|---|---|---|---|
|
noise/stabilization | formatting, imports | just apply it, don’t argue |
|
blocker | broken behavior or test compilation | fix immediately |
|
high risk | potential defect | almost always fix right after tests |
|
style/maintainability (sometimes risk) | bad patterns, agreements | fix after blockers and vet |
And now a small technique for reading file:line: open the file, jump to the line, look at 5–10 lines around it. Almost always the real problem is clearer in context than in a single line of output.
7. Mini scenario: run the gate and fix things step by step
You really want the quality gate to be not theory, but the feeling of “I can do this.” So let’s assemble a realistic scenario: you touched the task output function, added a small “improvement,” and everything went sideways.
We made a change and accidentally added a bug
package task
func Line(t Task) string {
if t.Done {
return "[x]" + t.Title // forgot the space
}
return "[ ]" + t.Title
}
Run gofmt/goimports
Suppose formatting doesn’t change meaning, but it stabilizes the code. You do:
gofmt -w .
goimports -w .
At this step we don’t “check,” we bring the project into the standard shape.
Run go test ./... and get a red signal
The test fails, and that’s a blocker. You fix exactly that, rather than arguing about error message style.
Fix:
func Line(t Task) string {
if t.Done {
return "[x] " + t.Title
}
return "[ ] " + t.Title
}
Run go vet ./... and catch a suspicious Printf
In parallel, somewhere in main.go you did:
package main
import "fmt"
func main() {
count := "10"
fmt.Printf("%d\n", count) // vet won't like this
}
Fix it:
fmt.Printf("%s\n", count) // print a string as a string
// 10
(Yes, sometimes the fix is just an honest %s.)
Run the linter and align with agreements
At that point, some “cosmetics” may surface that you can correct. For example, error style:
var ErrInvalidTitle = errors.New("invalid title") // ok by style
After that, you end up in a state where the project doesn’t just “somehow build,” but passes a minimal quality standard.
8. Common mistakes when running fmt/test/vet/lint
Mistake #1: run everything “in random order” and then drown in the output.
If you run checks in a random order, you’re almost guaranteed to fix the wrong thing at the wrong time. As a result, some changes will be unnecessary, some will conflict, and your brain will start hating the tools. The cure is simple: lock in the order fmt → test → vet → lint and repeat it the same way every time, like brushing your teeth (just without toothpaste on the keyboard).
Mistake #2: start with the linter when tests are red.
Linters can be chatty, and to a beginner it seems like “if there are more of them, they must be more important.” In practice it’s the opposite: red tests are a direct signal that behavior is broken. Until tests are green, it’s too early to discuss “improving readability”: first put out the fire, then pick the curtain color.
Mistake #3: treat go vet as style nitpicking.
vet is rarely about style; it’s more often about “oops, this can lead to unexpected behavior.” Stories with Printf formats are especially common. If you ignore vet, you’re stockpiling time bombs: they don’t always explode right away, but when they do, it’s maximally unpleasant.
Mistake #4: “fix without understanding,” just to make the message go away.
When a tool complains, it’s easy to slip into “remove the red stuff” mode. But if you don’t understand the rule’s meaning, you often fix the symptom, not the cause, and a week later you get a similar issue somewhere else. A healthy strategy: first understand why it’s a signal, then fix the code so it becomes more obvious and predictable.
Mistake #5: enable dozens of linters at once and declare war on your own project.
If you enable a huge set of checks without a policy, you get lots of noise and little value. Developers start turning rules off randomly or ignoring output entirely. It’s much healthier to start with a small set (for example, staticcheck and basic rules), train the team into the discipline, and only then expand.
GO TO FULL VERSION