« blog 2025-03-09

Bring Your Own Linter

This is a practical guide for integrating a custom linter into your Go project.

Finally, this is a rant about tool complexity and my defense of a simpler Go metalinter: glint.

Write a custom linter: analysis

The golang.org/x/tools/go/analysis package is the rich soil to Go’s linter ecosystem. A simple linter typically defines an analysis.Analyzer — or, for a complex linter, a graph of several mutually dependent Analyzers — that passes over files line-by-line and publishes diagnostics about them. The package’s published overview is short and very clear; if you want to write or debug lint rule, go read that overview.

nilinterface defines a single Analyzer without any dependencies: it inspects each node in each file’s AST, and uses pass.Reportf to alert if any given node is the literal nil and serving as an interface-typed argument to a function.

Note where the Analyzer is defined: in its own package, pkg/analyzer. All the code therein is strictly independent of what tools you use to run the analysis (whether a standalone command-line binary or a metalinter like golangci-lint); the package naturally separates concerns between the analysis itself and the means of invoking it.

The analysistest subpackage lets you test your Analyzer by feeding it real code samples, with expected diagnostics as inline code comments, from a testdata directory. See analyzer_test.go for an example.

One tip: LLM code generation is really useful for writing Analyzers, so long as you can precisely describe the linter behavior you want. Let the AI massage the polymorphic AST types so you can focus on the specific diagnostic conditions. Test-driven development works well here: write your analysistest examples (start with true-positive cases, issues you want the linter to detect) so you can validate the AI-generated contributions.

Build a standalone linter; run it

The simplest means of invoking an analysis.Analyzer is to build it into a standalone binary you can run from the command-line. The analysis package kindly provides a one-liner utility for that: just create a package main and call singlechecker.Main.

package main

import (
    "github.com/lukasschwab/nilinterface/pkg/analyzer"

    "golang.org/x/tools/go/analysis/singlechecker"
)

// main is an entry-point for running the nilinterface analyzer as a standalone
// binary. The analyzer process exposed by [singlechecker.Main] is additionally
// compatible with `go vet -vettool`.
func main() {
    singlechecker.Main(analyzer.Analyzer)
}

Congratulations! Before, you just had an analyzer; now you have a linter. You can install this main package and run it like you would any other command-line tool:

go install github.com/lukasschwab/nilinterface/cmd/nilinterface@latest
nilinterface --help
nilinterface ./...

Moreover, this singlechecker.Main command line interface is compatible with go vet:

go vet --vettool=$(which nilinterface) ./...

This is comfortable enough for one or two custom rules, but building and invoking so many individual binaries is tedious for a complex project. That’s where metalinters like golangci-lint come in: they coordinate a mass of smaller linters. Perhaps you’re already using golangci-lint for the bundle of linters it provides out of the box. How do you incorporate your custom linter into that bundle?

Customize golangci-lint

First, a rant. The amount of space dedicated to golangci-lint in this article is not to that project’s credit.

Before this weekend, I naturally conceived of metalinters as coordinators in an ecosystem of lightweight modular linters. That’s not quite right. golangci-lint, out of the box, is a gatekept1 fixed set of linters, baked into the tool at build time. Your massive .golangci.yml config? That’s not adding anything new — just picking rules from among what golangci-lint sees fit to offer.

Its list of linters belies an uncomfortable but inevitable tension. On one hand, there’s pressure to keep the list short: each linter makes the golangci-lint executable bigger; each one enabled by default makes it slower by default; linting with too many rules is unhelpful; and extending the list, especially with redundant options, makes selecting linters tedious. On the other hand, there’s reason to keep extending the list: there are lots of conceivably useful lint rules to provide.

The individual linters yield to this tension by being over-capable and configurable (i.e. non-atomic). Some (e.g. revive and staticcheck) are practically metalinters in their own right. These complex linters have complex configuration spaces — making them work for your project’s particular needs requires wading into the ls .*.yml mire you try not to think about.2

“He used single quotes instead of double quotes.” (Doré, Inferno #32)

The solution to this tension is proper modularity among atomic linters with minimal configurability. The only thing in your way is the trickiness of golangci-lint’s plugins system. It works — more below — but with rough docs and scant examples. It’s opaque.

The first thing to understand is that golangci-lint documents two different plugin systems: there’s the new module plugin system,3 which I’ll use here, and an older “Go plugin” system based on using cgo to compile linters as *.so files. Many custom-linter discussions online don’t explicitly specify one or the other; often, these are discussions about the Go plugin system because it came first.

If one red herring wasn’t enough, the module plugin system suggests there are two ways to add custom linters: “The Automatic Way” and “The Manual Way.” These are two ways to do the same thing: build a custom golangci-lint binary with some additional linters rolled in. You don’t need to learn them both. The “automatic way” specifies the new linters in a YAML file, then uses a build tool included in golangci-lint to integrate them; the “manual way” has you explicitly clone, modify, and build golangci-lint from source. I’ll use the “automatic way.”

There are tens of thousands of public GitHub repositories using golangci-lint; at time of writing, only 34 of them customize it with module plugins.

Adapt your Analyzer

golangci-lint doesn’t integrate with Analyzers directly, or through some analysis-provided interface. It needs to pass on the linter’s share of YAML settings, and it needs to understand what information about a program the linter analyzes.

It does both through a registration pattern defined in golangci/plugin-module-register.

  1. Wrap your analyzer to implement the interface register.LinterPlugin.

    In practice, you want to return one of the two string constants in register. There’s scant documentation on the difference between LoadModeSyntax and LoadModeTypesInfo — analyzers with LoadModeTypesInfo receive strictly more data, but may run a little slower.4 If you provide any other string, golangci-lint defaults to the data-rich mode.

  2. Define a factory function (this type is confusingly named NewPlugin) that receives untyped configuration from .golangci.yml and returns the LinterPlugin defined in step 1.

  3. Define an init function that calls register.Plugin to register the factory function defined in step 2.

I won’t get into the implementation details — for an atomic linter without configuration variables, this adapter package should be short and the logic largely reusable between linters. Here’s the adapter for nilinterface; I isolated all the golangci-specific logic in its own package so it doesn’t muddle the analysis.

Build the linter binary

At this point you can shift your focus from linter to lintee — the project you’re checking with golangci-lint. If you’re content without customization, linting this project is simple: you go install a command line tool, optionally provide it a configuration file, and you’re off to the races.

Using golangci-lint without plugins: provide it a YAML configuration (conventionally in .golangci.yml) when you analyze your code.

The “fixed set of linters” available in this installation are actually baked into the installed executable. Even if you haven’t enabled tagliatele, for example, it’s lurking in there somewhere.

Your new custom linter, unfortunately, is not — at least not until the maintainer accepts a PR adding it. The clever idea behind golangci’s module plugin system is that the linter executable you installed also includes a build tool, golangci-lint custom, for building another version of itself from scratch, with additional Go modules — Go modules that register LinterPlugins — rolled in!

Using golangci-lint with plugins (“The Automatic Way”) adds a build step: run golangci-lint custom -v to build a custom version of golangci-lint, then provide configuration to that binary to analyze your code. The custom-gcl tool is interchangeable with golangci-lint; it just has your custom linter baked in!

You define custom modules in the .custom-gcl.yml file, distinct and upstream from the familiar .golangci.yml file. Running the build tool yields a custom linter binary, which itself reads configuration from .golangci.yml like golangci-lint did before.

Don’t worry, this is nondestructive: the original golangci-lint executable you installed is retained unchanged. If you want to rip out custom linting, you can delete custom-gcl and .custom-gcl.yml and you’re back to the way things were.

For example, here’s a configuration file for a version of golangci-lint with nilinterface:

version: v1.63.4
plugins:
  - module: 'github.com/lukasschwab/nilinterface'
    import: 'github.com/lukasschwab/nilinterface/pkg/golangci-linter'
    version: v0.0.6

The module field defines a Go module, the same way you would import it in a go file. import identifies a package inside that module, the adapter package described above (see “Adapt your Analyzer). You could include several custom linters by defining plugins beyond this one.

Running custom-gcl is exactly the same as running bog-standard golangci-lint: the command-line interface and YAML settings are the same. Keep in mind, baking a linter into golangci-lint doesn’t mean it’ll run by default; you may need to update .golangci.yml to explicitly enable it. Using nilinterface as an example:

# This example file is grossly simplified.
linters-settings:
  custom:
    nilinterface:
      type: 'module'
      description: 'forbids passing `nil` as an interface argument to function calls'

# If you have `disable-all: true`, add nilinterface to your enabled linters.
linters:
  disable-all: true
  enable:
    - nilinterface

With the preceding changes to our YAML files, we can go from a clean golangci-lint to our custom analysis with this pair of shell commands:

golangci-lint custom -v
./custom-gcl run ./...

The first command, the build step, is way too slow to run every time you need to lint your project. golangci-lint custom downloads and builds golangci-lint from source — not a fast process. Of course, it doesn’t make sense to commit a binary built for your system into shared source control either… so sometimes you’re gonna have to suck it up and rebuild the thing.

Thankfully, you only need to rebuild this custom linter binary when you change .custom-gcl.yml. Makefiles are great for conditioning builds on file changes, and lazily building only what’s definitely necessary. If you define targets in your project Makefile like so —

.PHONY: lint
lint: custom-gcl
    ./custom-gcl run

custom-gcl: .custom-gcl.yml
    golangci-lint custom -v

— you can run make lint in lieu of the pair of shell commands above. The slow golangci-lint custom process will only run when custom-gcl (the executable it builds) is out of date.

Custom golangci-lint in VS Code

There’s a silver lining to building a drop-in replacement for golangci-lint: tools that integrate tightly with golangci-lint can integrate tightly with your custom-gcl instead. If you use VS Code, specify it as an alternate lint tool to see your custom linter applied in the editor:

{
  "go.lintTool": "golangci-lint",
  "go.alternateTools": {
    "golangci-lint": "${workspaceFolder}/custom-gcl"
  }
}

${workspaceFolder} dynamically evaluates to your current VS Code workspace. If you’re sharing a single linter across several workspaces, specify the binary’s absolute path instead.

Custom golangci-lint in GitHub Actions

The golangci-lint team maintains an official GitHub Action for running their linter, but it has no support for custom linter modules as I write this: it’ll use your .golangci.yml, but totally ignore the custom linters pinned in .custom-gcl.yml.

I built an alternative: golangci-lint-custom-plugins-action should tide you over until the official Action supports the same. This alternative Action follows the steps diagrammed above: download golangci-lint, use it to build a customized version of itself, then run that customized version. Like the official Action, this one persists linter binaries and caches to save time.

Getting back to basics

The foregoing complexity stems from an unforced but fundamental aspect of golangci-lint’s design: the decision to ship a binary, not a library, customizable through a baroque system of configuration files rather than as code. The underlying code here, the analyzers themselves, have reasonable APIs with unambiguous documentation.

Consider the problem of determining whether a linter config is valid. To ship a closed binary, golangci-lint reads configuration from a file — typically .golangci.yml — with no application-specific types. This is a big declarative hullaballoo for a process that, when you configure an Analyzer imperatively in Go, is entirely guaranteed by the type system.

We can — and should — return our focus from the tool to the code. We should consider custom extensibility a first-class priority in a metalinter.

You should be able to set up a linter to your Go project by adding a standalone cmd/lint/main.go “metalinter” program:

  1. Explicitly imports and constructs only the analyzers you want to run, using their exported types to configure them.

  2. Runs those analyzers as a group, outputting their diagnostics for consumption by other tools.

Why are we all still using golangci-lint?

The most important answer is performance, and the most significant factor is golangci-lint’s cache: if a package hasn’t changed, neither have the lint results. The metalinter stores per-package results indexed by hashes of the package contents. Luckily, there’s nothing really special going on: golangci-lint forks the native Go build cache and wraps it in package-hashing logic.

Less importantly, golangci-lint has a first-movers advantage: it filled the vacuum left by go lint’s 2020 deprecation, and Go engineers have been building (and squabbling over) its gatekept linter-set ever since.

An experimental metalinter: glint

In glint, I’m solving those problems for myself. That project aims to provide a minimal harness for running sets of analyzers defined in Go, with

Focusing on analysis.Analyzer means drop-in compatibility with the major golangci-lint linters: pkg/golangci describes them, and cmd/glint demos wrapping them in the harness.

Adding a custom linter is a matter of defining an analyzer and adding it to the list passed to glint.Main. These programs work with VS Code and go vet -vettool, and yield annotations in GitHub Actions.

Why use glint over analysis/multichecker?
multichecker is the package Go ships to help you solve this metalinter problem: “[t]his package makes it easy for anyone to build an analysis tool containing just the analyzers they need.”
Unfortunately, analysis/multichecker emits diagnostics to os.Stderr, which VS Code takes as an indication the lint run is erroring rather than revealing issues. Its public API also isn’t easy to extend — it calls os.Exit and depends on a complex group of internal packages.
What’s next for glint?
Improved performance with caching, especially for incremental runs on large project (near-instantaneous for golangci-lint).
Storing go vet results in the Go build cache and stressing usage with go vet -vettool is an interesting idea, but might require new workarounds for use with VS Code.

References

Reference Description
analysis Go’s static analysis builtins: Analyzer, singlechecker, etc. Strong docs.
nilinterface A model custom linter.
golangci-lint-custom-plugins-action GitHub Action for building, caching, and running golangci-lint with a custom linter.
tiir@5fe5d30 Diff upgrading a project from standard golangci-lint to a custom golangci-lint.
glint An experimental Go-defined metalinter.