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

  1. Ensure go is installed and on PATH.
  2. Install pre-commit once.
  3. Run pre-commit install in the repo.
  4. Commit as usual—hooks enforce format and lint.
  5. 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. Try export PATH="$(go env GOPATH)/bin:$PATH".
  • Massive first diff — run gofumpt -w . and golangci-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.