Add Opinionated Formatting and Linting to a Go Project
You ship faster when your codebase looks the same everywhere. Formatting and linting remove style debates, catch bugs early, and keep reviews focused on behavior—not whitespace. In this guide, you’ll add two things to any Go repo:
Formatting with gofumpt
(a stricter gofmt
).
- Linting with
golangci-lint
(one fast runner for many linters). - Automation with
pre-commit
so checks run before every commit.
The result: clean diffs, fewer nits, and confident merges.
1) Install the tools
Install gofumpt
and golangci-lint
in your dev environment. These commands put binaries in your $GOBIN
/$GOPATH/bin
or $HOME/go/bin
.
# From Go 1.22+ (works on 1.20+ too)
go install mvdan.cc/gofumpt@latest
# Install golangci-lint (fast multi-linter)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
Also install pre-commit
(a small Python package that manages Git hooks):
# macOS
brew install pre-commit
# or use pipx/pip
pipx install pre-commit
# or
python3 -m pip install --user pre-commit
2) Create a baseline config
For this we'll start from the Getting Started with Go repo at: https://github.com/markcallen/hello-go-example
Add a .golangci.yml
file at the repo root. Tune it per project, but this is a solid, pragmatic starting point for teams.
# .golangci.yml
run:
timeout: 3m
tests: true
go: "1.22"
linters-settings:
staticcheck:
checks: ["all"]
govet:
enable-all: true
revive:
# Keep rules minimal at first—raise later as the team stabilizes.
rules:
- name: exported
severity: warning
gocyclo:
min-complexity: 15
errcheck:
# Catch unchecked errors, but allow _ in tests.
exclude-functions:
- (*testing.T).Error
- (*testing.T).Fatal
- (*testing.T).Fatalf
- (*testing.T).Log
- (*testing.T).Logf
- (*testing.T).Errorf
linters:
enable:
- gosimple
- govet
- staticcheck
- revive
- errcheck
- ineffassign
- gocyclo
- gofumpt # Format check; pairs with the gofumpt formatter
disable:
- depguard # Turn on later when module boundaries harden
- dupl # Too noisy early on
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
exclude-dirs:
- vendor
exclude-rules:
- path: _test\.go
linters:
- gocyclo
Commit that file:
git add .golangci.yml && git commit -m "chore: add golangci-lint config"
3) Wire up pre-commit
Add a .pre-commit-config.yaml
to the repo root so formatting and linting run before you commit. We use official hooks for gofumpt
and golangci-lint
, plus one local hook for go mod tidy
to avoid drifting go.mod
/go.sum
changes.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 # Use the latest stable version
hooks:
- id: check-added-large-files
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/Bahjat/pre-commit-golang
rev: v1.0.5
hooks:
- id: gofumpt
- repo: https://github.com/golangci/golangci-lint
rev: v2.3.1 # Use the latest release
hooks:
- id: golangci-lint
# Keep modules tidy so CI and local builds match
- repo: local
hooks:
- id: go-mod-tidy
name: go mod tidy
entry: bash -c 'go mod tidy && git add go.mod go.sum'
language: system
files: ^(go\.mod|go\.sum)$
pass_filenames: false
Install the Git hook:
pre-commit install
Now try it:
# Formats code and runs lint when you commit
git add .
git commit -m "feat: add first service"
If a hook fails, pre-commit
blocks the commit and shows what to fix. Rerun on demand:
pre-commit run --all-files
4) Make formatting reproducible in CI
You want the same behavior locally and in CI. Add a quick GitHub Actions workflow that runs gofumpt
and golangci-lint
. It fails the build if files need formatting or if linters find issues.
# .github/workflows/ci.yaml
name: ci
on:
pull_request:
push:
branches: [ main ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Cache Go build
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install tools
run: |
go install mvdan.cc/gofumpt@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: Check formatting (gofumpt)
run: |
# Fail if formatting is needed
DIFF=$(gofumpt -l .)
if [ -n "$DIFF" ]; then
echo "These files need formatting:"
echo "$DIFF"
exit 1
fi
- name: Lint (golangci-lint)
run: golangci-lint run --out-format=colored-line-number --timeout=3m
This CI config makes formatting a non-negotiable contract. Developers see consistent failures, fix locally, and push clean diffs.
5) What to do when linters complain
Keep the signal high and the rules practical. A few patterns help:
- Fix, don’t fight. Most warnings are valid. Reduce complexity, handle errors, and simplify code.
- Upgrade gradually. Start with a smaller set of linters. Ratchet up as the team settles in.
- Document decisions. If you disable a rule, explain why in
.golangci.yml
. Future you will thank present you. - Suppress rarely—and locally. Use
//nolint:<linter>
sparingly and always add a short reason.
Example suppression with context:
// nolint:gocyclo // the state machine is intentionally branchy; split would harm readability
func (m *Machine) Step(evt Event) error {
// ...
}
6) Developer onboarding checklist
- Ensure
go
is installed and on PATH. - Install
pre-commit
once. - Run
pre-commit install
in the repo. - Commit as usual—hooks enforce format and lint.
- Use
pre-commit run --all-files
before large refactors to clean the slate.
Drop these lines in your project README to set expectations for contributors.
7) Troubleshooting quick hits
- Hook can’t find binaries — ensure
$GOBIN
is on your PATH. Tryexport PATH="$(go env GOPATH)/bin:$PATH"
. - Massive first diff — run
gofumpt -w .
andgolangci-lint run
once on a formatting branch. Merge it alone to keep later diffs clean. - Slow linting — cache modules in CI, limit directories with
run.skip-dirs
, and avoid ultra-noisy linters early on. - Go version drift — pin a single Go version in both
.golangci.yml
and CI.
8) Why gofumpt
instead of raw gofmt
?
gofmt
is the baseline. gofumpt
tightens it with small, opinionated rules that improve readability and consistency—without bikeshedding. It’s widely adopted, safe to run, and compatible with gofmt
output.
9) One-time “make it pretty” task
Many teams run this once when introducing formatting and linting:
gofumpt -w .
golangci-lint run ./... # fix issues; commit in small passes
Create a single PR titled “chore: enforce gofumpt + golangci-lint.” Merge it cleanly, then enforce via CI.
10) Optional: Makefile targets
Standard targets make local workflows predictable.
.PHONY: fmt lint tidy check
fmt:
gofumpt -w .
lint:
golangci-lint run --timeout=3m
tidy:
go mod tidy
check: fmt tidy lint
@echo "OK"
Wrap up
Fast teams automate the boring parts. gofumpt
formats the code. golangci-lint
keeps it honest. pre-commit
makes it automatic—before problems hit the repo. Add these three, and your Go project will feel smoother to work on and easier to review.
If you adopt only one habit this sprint, adopt this one. Your future PRs will read like a book.