This will be a practical tutorial on how to write a custom Go-langlinter. It will be production ready and will even enable you to add more linters that you didn't write. This will help enrich your CI pipeline and make it be able to detect issues before they are merged.
We will make our linter be a plugin to golangciβlint which has many linters supported. Our linter will also work standalone as an executable, but if you want to incorporate other linters, then integrating with golangciβlint will allow you to enable/disable other linter plugins that can come in handy.
Here is a link for the list of available linters
The default linter in IDEs is staticcheck, but for your CI I recommend running gosec plug-in along side your custom linter.
We will see how to enable/disable linters in this tutorial.
The code is available in this github repo
What We'll Build
| Linter | What it does |
|---|---|
| fmtlint | Forbids fmt.Print, fmt.Println, and fmt.Printf β production code should use a structured logger. |
| todolint | Requires TODO / FIXME comments to include an author attribution, e.g. // TODO(Mohammed): β¦
|
Both linters live in one plugin module, but each is registered as a
separate linter that can be enabled or disabled independently in
.golangci.yml.
The final product of our linter will be an executable which we can run against the project we want to lint. We have two options on how to incorporate the linter with our main project.
- Option 1. Add the linting project as a sub-project of the main project and mark it as a sub module in
go.workfile. - Option 2. Build the linters somewhere else and have their executable paths added to
PATHvariable
If your linter will be used for only one repo, in other words it's detecated to a single project, then option 1 is neater as it makes everything in one repo (monorepo). However, if you will use that linter for multiple projects then it makes sense to go with option
In this tutorial we will go with option 1. So we will have our main project, and then we will have a tools/customlinters directory that has our 2 linters.
π¦ Prerequisites
- Go 1.23 or later ( I am using go 1.25)
- golangciβlint v2 (v2.10.1 or later recommended)
Installing golangci-lint
You can either install it locally to your project or globally.
I recommend installing it globally so not pollute your project's dependencies.
Here is the official link for installation
For convenience here is a bash script to download it if it doesn't already exist. This script will work on Mac and Linux, for Windows you can look for instructions in the link aboce or use my script with a terminal that supports bash (so not powershell, maybe try WSL or Cygwin).
## install it if doesn't exists
export PATH="$PATH:$(go env GOPATH)/bin"
if ! command -v golangci-lint >/dev/null 2>&1; then
echo "Downloading golangci-lint..."
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.10.1
fi
π Project Structure
linter_tutorial/
βββ go.mod # root project module
βββ main.go # main_project entry point
main_project
βββ do_something.go # example code to lint
βββ .golangci.yml # linting configuration
βββ .custom-gcl.yml # plugin build configuration
βββ tools/
βββ customlinters/ # plugin module (separate go.mod)
βββ go.mod
βββ go.sum
βββ plugin.go # plugin entry point β registers both linters
βββ plugin_test.go # plugin registration tests
βββ analyzers/
βββ fmtlint.go # fmt.Print* analyzer
βββ fmtlint_test.go # fmtlint unit tests
βββ todolint.go # TODO/FIXME analyzer
βββ todolint_test.go # todolint unit tests
βββ testdata/
βββ src/
βββ fmtbad/ # test fixture: code that triggers fmtlint
βββ fmtgood/ # test fixture: code that passes fmtlint
βββ todobad/ # test fixture: code that triggers todolint
βββ todogood/ # test fixture: code that passes todolint
π¨ Step 1: Set Up the Projects
1.1 Root project
mkdir myproject && cd myproject
mkdir main_project
go mod init myproject
1.2 Plugin module
mkdir -p tools/customlinters/analyzers
cd tools/customlinters
go mod init customlinters
go get github.com/golangci/plugin-module-register@latest
go get golang.org/x/tools/go/analysis
go get golang.org/x/tools/go/analysis/analysistest
go mod tidy
cd ../..
Your tools/customlinters/go.mod should look similar to:
module customlinters
go 1.23
require (
github.com/golangci/plugin-module-register v0.1.2
golang.org/x/tools v0.42.0
)
π§ Step 2: Write the Analyzers
Each analyzer is a plain Go file that exposes an *analysis.Analyzer. The
analyzer uses Go's go/ast package to inspect the syntax tree.
2.1 fmtlint β forbid fmt.Print* calls
Create tools/customlinters/analyzers/fmtlint.go:
package analyzers
import (
"go/ast"
"golang.org/x/tools/go/analysis"
)
// FmtLintAnalyzer forbids the use of fmt.Print, fmt.Println, and fmt.Printf.
//
// Production code should use a structured logger (e.g. log/slog) instead of
// printing directly to stdout.
var FmtLintAnalyzer = &analysis.Analyzer{
Name: "fmtlint",
Doc: "forbids the use of fmt.Print, fmt.Println, and fmt.Printf",
Run: runFmtLint,
}
func runFmtLint(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
// Look for function call expressions.
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Look for selector expressions like fmt.Print.
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// Check if the package qualifier is "fmt".
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok || pkgIdent.Name != "fmt" {
return true
}
// Flag Print, Println, and Printf.
switch sel.Sel.Name {
case "Print", "Println", "Printf":
pass.Reportf(call.Pos(), "avoid using fmt.%s; use a structured logger instead", sel.Sel.Name)
}
return true
})
}
return nil, nil
}
How it works
- Iterates over every file in the package under analysis.
- Walks the AST looking for
*ast.CallExprnodes (function calls). - Checks whether the call target is a selector expression whose left side is
the identifier
fmt. - If the function name is
Print,Println, orPrintf, it reports a diagnostic at that position.
2.2 todolint β require author on TODO/FIXME comments
Create tools/customlinters/analyzers/todolint.go:
package analyzers
import (
"go/ast"
"strings"
"golang.org/x/tools/go/analysis"
)
// TodoLintAnalyzer flags TODO and FIXME comments that are missing an author
// attribution.
//
// Good: // TODO(alice): refactor this function
// Bad: // TODO: refactor this function
// Bad: // TODO refactor this function
var TodoLintAnalyzer = &analysis.Analyzer{
Name: "todolint",
Doc: "requires TODO/FIXME comments to include an author: // TODO(name): ...",
Run: runTodoLint,
}
// prefixes are the comment keywords we check for.
var prefixes = []string{"TODO", "FIXME"}
func runTodoLint(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
for _, cg := range file.Comments {
for _, comment := range cg.List {
checkComment(pass, comment)
}
}
}
return nil, nil
}
// checkComment inspects a single comment for a TODO/FIXME missing an author.
func checkComment(pass *analysis.Pass, comment *ast.Comment) {
// Strip the leading // or /* and trim whitespace.
text := comment.Text
if strings.HasPrefix(text, "//") {
text = strings.TrimPrefix(text, "//")
} else if strings.HasPrefix(text, "/*") {
text = strings.TrimPrefix(text, "/*")
text = strings.TrimSuffix(text, "*/")
}
text = strings.TrimSpace(text)
for _, prefix := range prefixes {
if !strings.HasPrefix(text, prefix) {
continue
}
rest := text[len(prefix):]
// Good form: TODO(author)
if strings.HasPrefix(rest, "(") {
return
}
// Anything else is missing an author.
pass.Reportf(comment.Pos(), "%s comment is missing an author: use // %s(author): ...", prefix, prefix)
return
}
}
How it works
- Iterates over every comment group in every file.
- Strips the
//or/* */delimiters and trims whitespace. - Checks if the comment starts with
TODOorFIXME. - If the very next character is
(, the comment has proper attribution and is accepted (// TODO(alice): β¦). - Otherwise it reports a diagnostic.
Note how the two analyzers demonstrate different techniques: fmtlint
walks the AST node tree for function calls, while todolint iterates over the
comment list. Both use the same*analysis.PassAPI to report diagnostics.
π§ Step 3: Write the Plugin Entry Point
The plugin entry point registers each linter with golangciβlint's module
plugin system. A single module can register multiple linters β each one
can be enabled or disabled independently in .golangci.yml.
Create tools/customlinters/plugin.go:
// Package customlinters registers custom golangci-lint module plugins.
//
// Each register.Plugin call makes a linter available by name in .golangci.yml.
// A single module can register multiple linters β each one can be enabled or
// disabled independently.
package customlinters
import (
"customlinters/analyzers"
"github.com/golangci/plugin-module-register/register"
"golang.org/x/tools/go/analysis"
)
func init() {
register.Plugin("fmtlint", newFmtLint)
register.Plugin("todolint", newTodoLint)
}
// ---------------------------------------------------------------------------
// fmtlint plugin
// ---------------------------------------------------------------------------
type fmtLintPlugin struct{}
func newFmtLint(settings any) (register.LinterPlugin, error) {
return &fmtLintPlugin{}, nil
}
func (p *fmtLintPlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{analyzers.FmtLintAnalyzer}, nil
}
func (p *fmtLintPlugin) GetLoadMode() string {
return register.LoadModeSyntax
}
// ---------------------------------------------------------------------------
// todolint plugin
// ---------------------------------------------------------------------------
type todoLintPlugin struct{}
func newTodoLint(settings any) (register.LinterPlugin, error) {
return &todoLintPlugin{}, nil
}
func (p *todoLintPlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{analyzers.TodoLintAnalyzer}, nil
}
func (p *todoLintPlugin) GetLoadMode() string {
return register.LoadModeSyntax
}
Key points
The package must NOT be
mainβ it must be an importable package (here,
package customlinters). Thegolangci-lint customcommand blankβimports
the plugin module (_ "customlinters") to trigger theinit()function.
Go does not allow importingmainpackages.Each
register.Plugin()call creates a separatelyβenableable linter.
The first argument is the linter name used in.golangci.yml.register.LoadModeSyntaxtells golangciβlint that these analyzers only need
the parsed AST, not full typeβchecking. This makes them faster.
π§ͺ Step 4: Write Unit Tests
4.1 How analysistest works
The golang.org/x/tools/go/analysis/analysistest package is the standard test
framework for Go analyzers. It:
- Loads Go source files from a
testdata/src/<package>/directory. - Runs your analyzer against them.
- Verifies that
// wantcomments in the source match the diagnostics produced.
Each // want comment contains a regex that must match a diagnostic
reported at that line. If an expected diagnostic is missing, or an unexpected
one appears, the test fails. Use backtickβdelimited strings for the regex to
avoid escaping issues.
4.2 Test fixtures
Create these files under tools/customlinters/analyzers/testdata/src/:
fmtbad/fmtbad.go β code that should trigger fmtlint:
package fmtbad
import "fmt"
func UsePrint() {
fmt.Print("hello") // want `avoid using fmt\.Print; use a structured logger instead`
fmt.Println("world") // want `avoid using fmt\.Println; use a structured logger instead`
fmt.Printf("%s\n", "test") // want `avoid using fmt\.Printf; use a structured logger instead`
}
fmtgood/fmtgood.go β code that should pass fmtlint (no diagnostics):
package fmtgood
import (
"fmt"
"log"
)
// Using fmt.Sprintf is fine β it doesn't print to stdout.
func FormatString() string {
return fmt.Sprintf("value: %d", 42)
}
// Using log is fine β it's a structured output.
func UseLog() {
log.Println("structured logging")
log.Printf("formatted: %d", 1)
}
// The builtin println is fine β it's not fmt.Println.
func UseBuiltin() {
println("builtin")
}
// Using fmt.Errorf is fine β it returns an error, not stdout.
func MakeError() error {
return fmt.Errorf("something went wrong: %d", 42)
}
todobad/todobad.go β code that should trigger todolint:
package todobad
// TODO: refactor this function // want `TODO comment is missing an author`
func NeedsRefactor() {}
// FIXME: this is broken // want `FIXME comment is missing an author`
func IsBroken() {}
// TODO fix the edge case // want `TODO comment is missing an author`
func EdgeCase() {}
func Inline() {
_ = 1 // TODO clean this up // want `TODO comment is missing an author`
}
todogood/todogood.go β code that should pass todolint:
package todogood
// TODO(alice): refactor this function
func WellAttributed() {}
// FIXME(bob): handle the edge case
func AlsoGood() {}
// This is a regular comment, no TODO or FIXME.
func RegularComment() {}
// Something about a todomvc framework β not a TODO marker.
func NotATodo() {}
4.3 Analyzer test files
tools/customlinters/analyzers/fmtlint_test.go:
package analyzers_test
import (
"testing"
"customlinters/analyzers"
"golang.org/x/tools/go/analysis/analysistest"
)
func TestFmtLintAnalyzer_Bad(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, analyzers.FmtLintAnalyzer, "fmtbad")
}
func TestFmtLintAnalyzer_Good(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, analyzers.FmtLintAnalyzer, "fmtgood")
}
tools/customlinters/analyzers/todolint_test.go:
package analyzers_test
import (
"testing"
"customlinters/analyzers"
"golang.org/x/tools/go/analysis/analysistest"
)
func TestTodoLintAnalyzer_Bad(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, analyzers.TodoLintAnalyzer, "todobad")
}
func TestTodoLintAnalyzer_Good(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, analyzers.TodoLintAnalyzer, "todogood")
}
4.4 Plugin registration tests
tools/customlinters/plugin_test.go:
package customlinters
import (
"testing"
"github.com/golangci/plugin-module-register/register"
)
// ---------------------------------------------------------------------------
// fmtlint plugin tests
// ---------------------------------------------------------------------------
func TestNewFmtLint(t *testing.T) {
p, err := newFmtLint(nil)
if err != nil {
t.Fatalf("newFmtLint(nil) returned error: %v", err)
}
if p == nil {
t.Fatal("newFmtLint(nil) returned nil")
}
}
func TestFmtLintBuildAnalyzers(t *testing.T) {
p, _ := newFmtLint(nil)
as, err := p.BuildAnalyzers()
if err != nil {
t.Fatalf("BuildAnalyzers() error: %v", err)
}
if len(as) != 1 {
t.Fatalf("expected 1 analyzer, got %d", len(as))
}
if as[0].Name != "fmtlint" {
t.Errorf("expected name %q, got %q", "fmtlint", as[0].Name)
}
}
func TestFmtLintGetLoadMode(t *testing.T) {
p, _ := newFmtLint(nil)
if mode := p.GetLoadMode(); mode != register.LoadModeSyntax {
t.Errorf("expected %q, got %q", register.LoadModeSyntax, mode)
}
}
// ---------------------------------------------------------------------------
// todolint plugin tests
// ---------------------------------------------------------------------------
func TestNewTodoLint(t *testing.T) {
p, err := newTodoLint(nil)
if err != nil {
t.Fatalf("newTodoLint(nil) returned error: %v", err)
}
if p == nil {
t.Fatal("newTodoLint(nil) returned nil")
}
}
func TestTodoLintBuildAnalyzers(t *testing.T) {
p, _ := newTodoLint(nil)
as, err := p.BuildAnalyzers()
if err != nil {
t.Fatalf("BuildAnalyzers() error: %v", err)
}
if len(as) != 1 {
t.Fatalf("expected 1 analyzer, got %d", len(as))
}
if as[0].Name != "todolint" {
t.Errorf("expected name %q, got %q", "todolint", as[0].Name)
}
}
func TestTodoLintGetLoadMode(t *testing.T) {
p, _ := newTodoLint(nil)
if mode := p.GetLoadMode(); mode != register.LoadModeSyntax {
t.Errorf("expected %q, got %q", register.LoadModeSyntax, mode)
}
}
4.5 Run the tests
cd tools/customlinters
go test ./... -v
Expected output:
=== RUN TestNewFmtLint
--- PASS: TestNewFmtLint (0.00s)
=== RUN TestFmtLintBuildAnalyzers
--- PASS: TestFmtLintBuildAnalyzers (0.00s)
=== RUN TestFmtLintGetLoadMode
--- PASS: TestFmtLintGetLoadMode (0.00s)
=== RUN TestNewTodoLint
--- PASS: TestNewTodoLint (0.00s)
=== RUN TestTodoLintBuildAnalyzers
--- PASS: TestTodoLintBuildAnalyzers (0.00s)
=== RUN TestTodoLintGetLoadMode
--- PASS: TestTodoLintGetLoadMode (0.00s)
PASS
ok customlinters
=== RUN TestFmtLintAnalyzer_Bad
--- PASS: TestFmtLintAnalyzer_Bad (0.40s)
=== RUN TestFmtLintAnalyzer_Good
--- PASS: TestFmtLintAnalyzer_Good (0.19s)
=== RUN TestTodoLintAnalyzer_Bad
--- PASS: TestTodoLintAnalyzer_Bad (0.02s)
=== RUN TestTodoLintAnalyzer_Good
--- PASS: TestTodoLintAnalyzer_Good (0.02s)
PASS
ok customlinters/analyzers
π Step 5: Build a Custom golangciβlint Binary
5.1 Create .custom-gcl.yml in the project root
This file tells golangci-lint custom how to build the binary:
version: v2.10.1
name: custom-gcl
destination: ./build
plugins:
- module: customlinters
path: ./tools/customlinters
| Field | Purpose |
|---|---|
version |
Must match your installed golangciβlint version. |
name |
Name of the output binary. |
destination |
Directory where the binary is placed. |
plugins[].module |
The Go module name (from go.mod). |
plugins[].path |
Relative path to the plugin source. |
One plugin entry, two linters. Because both linters are registered via
init()in the same module, a single plugin entry is all you need. Each
register.Plugin()call creates a separate linter.
5.2 Build
golangci-lint custom -v
This will:
- Download the golangciβlint source at the specified version.
- Add your plugin module as a dependency.
- Inject a blank import (
_ "customlinters") to triggerinit(). - Compile everything into
./build/custom-gcl.
βοΈ Step 6: Configure the Linters
Create .golangci.yml in the project root:
version: "2"
linters:
default: none
enable:
- fmtlint
- todolint
settings:
custom:
fmtlint:
type: module
description: fmt custom linter
settings: {}
todolint:
type: module
description: todo custom linter
settings: {}
Because the linters are registered as separate plugins, you can enable them
independently. For example, to use only todolint:
version: "2"
linters:
default: none
enable:
- todolint
settings:
custom:
fmtlint:
type: module
description: fmt custom linter
settings: {}
todolint:
type: module
description: todo custom linter
settings: {}
π§ͺ Step 7: Try It Out
Create do_somethig.go in the project at ./main_program:
package main_program
import "fmt"
// TODO: replace with structured logging
func Something() {
fmt.Println("Hello, World!")
}
Run the custom linter:
./build/custom-gcl run ./main_project/...
Expected output β both linters fire:
main.go:5:1: TODO comment is missing an author: use // TODO(author): ... (todolint)
main.go:7:2: avoid using fmt.Println; use a structured logger instead (fmtlint)
π Appendix: Adding a Third Linter
To add another linter to the same module:
Create a new analyzer in
tools/customlinters/analyzers/yourlint.go
exporting avar YourLintAnalyzer = &analysis.Analyzer{β¦}.Register it in
plugin.goby adding anotherregister.Plugin()call
ininit():
func init() {
register.Plugin("fmtlint", newFmtLint)
register.Plugin("todolint", newTodoLint)
register.Plugin("yourlint", newYourLint) // β add this
}
Add tests with new
testdata/src/fixtures.Enable it in
.golangci.yml:
enable:
- fmtlint
- todolint
- yourlint
- Rebuild the custom binary:
golangci-lint custom -v
No changes to .custom-gcl.yml are needed β the new linter is in the same
module that's already listed.
How to add a gosec linter
change your .golangci.yml config to this, and rebuild. And tadaa you have yourself a new useful linter from the public linters.
version: "2"
linters:
default: none
enable:
- fmtlint
- todolint
- gosec
settings:
custom:
fmtlint:
type: module
description: fmt custom linter
settings: {}
todolint:
type: module
description: todo custom linter
settings: {}
Top comments (0)