Skip to main content

Toolchain and Static Analysis

For developers with .NET (C#) experience, this guide explains developer tools such as gofmt, go vet, golangci-lint, and go generate. The roles played in C# by Roslyn Analyzers, .editorconfig, dotnet format, and Source Generators are split in Go between standard toolchain commands and a small number of external tools. Go culture values leaving "nothing to argue about" with few settings, so this guide covers the commands to learn first and how to lock them in with CI.

1. What This Document Covers​

  • Non-negotiable formatting with gofmt
  • Static analysis with go vet and golangci-lint, and the v2 configuration
  • When to use go generate
  • Integrating into CI

Non-negotiable formatting with gofmt​

gofmt decides the single correct format for Go code. Indentation uses tabs, and the tool decides where braces and spaces go, so style comments in reviews and configuration debates within a team almost never happen.

  • gofmt -l . lists files that need formatting. gofmt -d . shows the diff, and gofmt -w . rewrites the files.
  • gofmt -s also applies safe simplifications, such as turning []T{T{...}} into []T{{...}}.
  • goimports (golang.org/x/tools/cmd/goimports) does everything gofmt does and also adds, removes, and groups imports. Most editors can run equivalent processing on save through gopls.
  • gofumpt is a stricter formatter that adds more rules while staying compatible with gofmt. Adopting it is optional, and it can be enabled as a formatter in golangci-lint.

Static analysis with go vet and golangci-lint​

go vet is the analysis built into the Go toolchain. It detects likely bugs such as mismatched Printf format strings, forgotten cancel calls for a context (lostcancel), and copying values that contain locks (copylocks). go test automatically runs a subset of the go vet checks.

golangci-lint is the de facto standard runner that executes many linters together. It runs fast thanks to parallel execution and caching, and shares its settings through .golangci.yml. staticcheck is integrated into it. In version 2 (v2) the configuration file format differs from v1, and you must write version: "2".

Areav2 keyContents
Linterslintersdefault (such as standard), enable / disable, and per-linter settings in linters.settings
Exclusionslinters.exclusionsPresets (such as comments) and exclusion rules by path or text
FormattersformattersEnable gofmt, goimports, gofumpt, and others with enable

default: standard enables errcheck, govet, ineffassign, staticcheck, and unused.

When to use go generate​

go generate simply finds special comments of the form //go:generate command in source code and runs them. It is not run automatically by go build or go test. Commit the generated output to the repository, and have CI verify that generation was not forgotten.

  • Generating String() methods for enumerated types with stringer
  • Generating mocks with mockgen or moq
  • Generating code from SQL or OpenAPI with sqlc or oapi-codegen
  • Generating Protocol Buffers code

By convention, generated files contain a comment of the form // Code generated ... DO NOT EDIT., and linters and editors use it to treat generated code specially.

Integrating into CI​

The basic approach is to use the same commands locally and in CI, and to check the following in order.

  1. Formatting (gofmt -l, or golangci-lint fmt --diff)
  2. go vet ./... and golangci-lint run
  3. Generated code and go.mod / go.sum are up to date (no diff after go generate ./... and go mod tidy)
  4. Tests (go test -race ./...; see Testing for details)
  5. Vulnerability check (govulncheck ./...)

CI for build and deployment is covered in Build and Deploy.

2. .NET Mapping​

.NET (C#)Go
dotnet format / EditorConfiggofmt / goimports
Roslyn Analyzersgo vet / staticcheck / golangci-lint
Source Generatorsgo generate + code generation tools

Additional mappings that complement the table above:

.NET (C#)Go
dotnet format --verify-no-changesCheck that the output of gofmt -l . is empty / golangci-lint fmt --diff
dotnet_diagnostic.*.severity in .editorconfiglinters / exclusions in .golangci.yml
TreatWarningsAsErrorsgolangci-lint run exits with code 1 when it finds issues
#pragma warning disable CA1822//nolint:linter-name // reason
Analyzer packages from NuGetLinters bundled with golangci-lint (no extra packages)
dotnet-tools.json (local tools)The tool directive in go.mod (Go 1.24+) and go tool
Source Generator (runs automatically during build)go generate (run explicitly, and commit the output)
T4 templatesA custom generator combining text/template and //go:generate
NuGet audit / dotnet list package --vulnerablegovulncheck ./...

3. Common Pitfalls for C# Developers​

There are no formatting options​

You don't need the .editorconfig habit of tuning indentation width or brace position. gofmt effectively has no settings, so everyone gets the same output. Just enable format-on-save in your editor.

gofmt -l exits with code 0 even when there are differences​

To make CI fail on unformatted code, you have to check yourself whether the output of gofmt -l . is empty. There is no dedicated flag like dotnet format --verify-no-changes. Using golangci-lint fmt --diff, or a setup where golangci-lint run includes formatters, makes this easier.

Analyzers don't run during the build​

In C#, Roslyn Analyzers run during compilation and show up in the build as warnings or errors. The Go compiler makes unused variables and unused imports errors, but does no other analysis. go vet and golangci-lint are separate commands that you must run yourself.

golangci-lint v1 and v2 configurations are not compatible​

Many articles online still show the v1 format (linters-settings, issues.exclude-rules, and so on). v2 requires version: "2" and reorganizes settings into linters.settings, linters.exclusions, and formatters. golangci-lint migrate converts an old configuration, and golangci-lint config verify validates a configuration file.

Don't enable every linter​

Enabling a large number of linters produces more opinionated findings and degrades the developer experience. Start from default: standard and add only the ones that are effective at catching bugs. When adopting it in existing code, the new-from-rev setting (issues.new-from-rev), which targets only newly written code, lets you introduce linting gradually.

Add a reason to //nolint​

Specify the target linter, as in //nolint:errcheck, and add a reason as a comment. A bare //nolint without a linter name silences every linter, so avoid it. Enabling nolintlint detects unused //nolint directives and ones without an explanation.

go generate doesn't run automatically​

Unlike a Source Generator, building does not generate anything. After changing a generation source, run go generate ./... and commit the output together. If you forget, the build can pass with stale generated output. Detect this in CI by checking for a diff after regeneration.

Align tool versions across the team​

If everyone runs go install foo@latest, tool versions differ from person to person. From Go 1.24 on, go get -tool records a tool directive in go.mod, and go tool name runs the version pinned in go.mod. For golangci-lint itself, the safe choice is to follow the official documentation, which recommends installing the binary or using the official GitHub Action.

4. Code Examples​

Example 1: Everyday commands​

# Formatting (show diff, rewrite, with simplification)
gofmt -l .
gofmt -d .
gofmt -s -w .

# Organize imports (install only once)
go install golang.org/x/tools/cmd/goimports@latest
goimports -w .

# Standard static analysis
go vet ./...

# golangci-lint v2
golangci-lint run
golangci-lint fmt # run formatters (rewrites files)
golangci-lint fmt --diff # show the diff only
golangci-lint config verify

# Vulnerability check for dependencies
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

These correspond to dotnet format and the IDE's code cleanup in C#.

Example 2: golangci-lint v2 configuration​

An example .golangci.yml. It builds on standard and adds linters that are effective at catching bugs.

version: "2"

run:
timeout: 5m

linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
enable:
- bodyclose # forgotten http.Response.Body close
- errorlint # places that should use errors.Is / errors.As
- gocritic
- misspell
- nolintlint # detect improper //nolint
- revive
- unparam
settings:
errcheck:
check-type-assertions: true
govet:
enable:
- shadow # variable shadowing
nolintlint:
require-specific: true # require the //nolint:linter-name form
require-explanation: true
exclusions:
presets:
- comments
- std-error-handling
rules:
- path: _test\.go
linters:
- errcheck
- unparam

formatters:
enable:
- gofumpt
- goimports
settings:
goimports:
local-prefixes:
- example.com/app

default accepts standard, all, none, and fast. Check the official documentation for the golangci-lint version you use for the individual settings.

Example 3: Writing //nolint​

// Good: restrict the linter name and add a reason
defer f.Close() //nolint:errcheck // read-only file, so the Close error can be ignored

// Bad: disables every linter
defer f.Close() //nolint

This corresponds to #pragma warning disable CA2000 and [SuppressMessage] in C#.

Example 4: go generate and the tool directive​

An example that generates String() for an enumerated type with stringer.

package color

//go:generate go tool stringer -type=Color

type Color int

const (
Red Color = iota
Green
Blue
)

Pin stringer in go.mod (Go 1.24+).

go get -tool golang.org/x/tools/cmd/stringer

A line like the following is added to go.mod.

tool golang.org/x/tools/cmd/stringer
go generate ./... # generates color_string.go
go tool stringer -help
go install tool # install every tool in go.mod into GOBIN

A go generate directive runs in the directory containing that file. Multiple directives run in order from the top. Up to Go 1.23, the common way to pin tools was a blank import in tools.go (_ "golang.org/x/tools/cmd/stringer" with //go:build tools).

Example 5: Lint and verification in GitHub Actions​

name: lint

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Check formatting
run: test -z "$(gofmt -l .)"

- name: Vet
run: go vet ./...

- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.5

- name: Verify generated code is up to date
run: |
go generate ./...
git diff --exit-code

- name: Verify go.mod is tidy
run: go mod tidy -diff

- name: Test
run: go test -race -cover ./...

- name: Vulnerability check
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

Pin the version of golangci-lint-action to the v2 release you use. go mod tidy -diff is available from Go 1.23, and it prints a diff and fails if changes are needed.

Example 6: The equivalent setup in C#​

On the C# side, a quality gate is commonly configured with Directory.Build.props and .editorconfig like this.

<Project>
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>
</Project>
# .editorconfig
[*.cs]
dotnet_diagnostic.CA2007.severity = none
dotnet_diagnostic.CA1062.severity = warning

In Go, a single .golangci.yml file and a sequence of CI commands play this role. The difference is that analysis is not part of the build itself, so you run it explicitly in CI.

5. Summary​

  • Leave formatting to gofmt (or goimports / gofumpt) and don't debate settings. In CI, check whether the output of gofmt -l is empty.
  • go vet is the standard static analysis, and golangci-lint runs many linters, including staticcheck, together. In v2 you configure it with version: "2", linters, and formatters.
  • Start linting from default: standard and add only high-value linters. Always write the linter name and a reason in //nolint.
  • go generate is a command you run explicitly, and the output is committed. In CI, regenerate and confirm there is no diff.
  • From Go 1.24 on, the tool directive in go.mod and go tool let you pin development tool versions.
  • In CI, check formatting, go vet, golangci-lint, consistency of generated code and go.mod, go test -race, and govulncheck, in that order.

References​