DEV Community

Mohammed Al-ameen
Mohammed Al-ameen

Posted on

Create a Go Linter

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.

  1. Option 1. Add the linting project as a sub-project of the main project and mark it as a sub module in go.work file.
  2. Option 2. Build the linters somewhere else and have their executable paths added to PATH variable

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
Enter fullscreen mode Exit fullscreen mode

πŸ“ 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
Enter fullscreen mode Exit fullscreen mode

πŸ”¨ Step 1: Set Up the Projects

1.1 Root project

mkdir myproject && cd myproject
mkdir main_project
go mod init myproject
Enter fullscreen mode Exit fullscreen mode

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 ../..
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

πŸ”§ 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
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Iterates over every file in the package under analysis.
  2. Walks the AST looking for *ast.CallExpr nodes (function calls).
  3. Checks whether the call target is a selector expression whose left side is the identifier fmt.
  4. If the function name is Print, Println, or Printf, 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
    }
}
Enter fullscreen mode Exit fullscreen mode

How it works

  1. Iterates over every comment group in every file.
  2. Strips the // or /* */ delimiters and trims whitespace.
  3. Checks if the comment starts with TODO or FIXME.
  4. If the very next character is (, the comment has proper attribution and is accepted (// TODO(alice): …).
  5. 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.Pass API 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
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • The package must NOT be main β€” it must be an importable package (here,
    package customlinters). The golangci-lint custom command blank‑imports
    the plugin module (_ "customlinters") to trigger the init() function.
    Go does not allow importing main packages.

  • Each register.Plugin() call creates a separately‑enableable linter.
    The first argument is the linter name used in .golangci.yml.

  • register.LoadModeSyntax tells 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:

  1. Loads Go source files from a testdata/src/<package>/ directory.
  2. Runs your analyzer against them.
  3. Verifies that // want comments 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`
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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`
}
Enter fullscreen mode Exit fullscreen mode

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() {}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

4.5 Run the tests

cd tools/customlinters
go test ./... -v
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

πŸ›  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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

This will:

  1. Download the golangci‑lint source at the specified version.
  2. Add your plugin module as a dependency.
  3. Inject a blank import (_ "customlinters") to trigger init().
  4. 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: {}
Enter fullscreen mode Exit fullscreen mode

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: {}
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ 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!")
}
Enter fullscreen mode Exit fullscreen mode

Run the custom linter:

./build/custom-gcl run ./main_project/...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

πŸ“– Appendix: Adding a Third Linter

To add another linter to the same module:

  1. Create a new analyzer in tools/customlinters/analyzers/yourlint.go
    exporting a var YourLintAnalyzer = &analysis.Analyzer{…}.

  2. Register it in plugin.go by adding another register.Plugin() call
    in init():

   func init() {
       register.Plugin("fmtlint", newFmtLint)
       register.Plugin("todolint", newTodoLint)
       register.Plugin("yourlint", newYourLint)   // ← add this
   }
Enter fullscreen mode Exit fullscreen mode
  1. Add tests with new testdata/src/ fixtures.

  2. Enable it in .golangci.yml:

   enable:
     - fmtlint
     - todolint
     - yourlint
Enter fullscreen mode Exit fullscreen mode
  1. Rebuild the custom binary:
   golangci-lint custom -v
Enter fullscreen mode Exit fullscreen mode

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: {}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)