---
title: Managing project tools with Go
date: 2025-05-12
description: Comparing approaches for managing non-application tool dependencies in Go projects, including go tool, tools.go, and go run.
tags: [go, make]
---

There are multiple ways to deal with non-application dependencies (i.e. "tools" that your project needs).

## go tool

As of Go 1.24 (Feb 2025)

To add a new tool:

```shell
go get -tool golang.org/x/lint/golint
go get -tool github.com/mgechev/revive@latest
```

To run the tool:

```shell
go tool golint -h
go tool golang.org/x/lint/golint -h # in case of naming overlap
```

To see a list of all tools:

```shell
go tool
```

To update all tools:

```shell
go get -u tool
```

If you check the `go.mod` you'll see a new tool syntax:

```go.mod
module testing-tools

go 1.23.4

tool (
    github.com/mgechev/revive
    golang.org/x/lint/golint
)
```

### Caveats and Issues

Now, there is a problem (sort of), which is that you'll see a bunch of _indirect_ dependencies showing up in the `go.mod`.

This is because these are the dependencies that your "tools" need.

I'm less concerned about that as a side-effect of using the new `go tools` feature, but I appreciate it's not ideal.

My concern being: it's more mental overhead.

You don't know if these _indirect_ dependencies are transient dependencies used by your application dependencies, or if they're dependencies for the "tools" you've installed.

The reason I'm not usually _that fussed_ by this is because I only really care about the "direct" dependencies, and those are always clear because they don't have `// indirect` following them.

**So the following instructions are only relevant if you really care about this**.

### Multiple Module Files

There is another option on the table that we can use, and it doesn't appear to be too much additional maintenance or mental overhead, which is great. But it does have a downside (see the `IMPORTANT` note at the end of this section).

Essentially, the approach is to have a separate modfile for tools.

It means we'd have multiple files now, like this...

```
go.mod
go.sum
tools.mod
tools.sum
```

> [!IMPORTANT]
> If you give the `tools.mod` a unique module name, let's say `go.mod` uses `github.com/example/foo`, and so you make `tools.mod` use `github.com/example/foo/tools` then be aware that the use of `go mod` isn't going to make your `tools.mod` think it needs the module from `go.mod` and it'll add it as a dependency (this makes things weird in special cases), so it might be worth making the module name the same between `go.mod` and `tools.mod`.

To install a new tool:

```bash
# instead of...
go get -tool github.com/mgechev/revive

# we do...
go get -modfile=tools.mod -tool github.com/mgechev/revive
```

> [!TIP]
> To _remove_ a tool you can do the above but set the version to `@none`.

And if we want to use that tool we have to make sure to specify the modfile:

```
$ go tool revive --version
go: no such tool "revive"

$ go tool -modfile=tools.mod revive --version
version 1.7.0
```

Having to specify the `-modfile` flag isn't a big issue as we already have `go tool` abstracted inside the various Makefile targets, so we should only ever be calling a Makefile target (or in the case of stringer have it codified in the go generate directive in the code itself).

As far as updating tools, you can either do it a dependency at a time or all of them at once:

```bash
# instead of...
go get -u -tool github.com/mgechev/revive@latest
go get -u tool

# we do...
go get -u -modfile=tools.mod -tool github.com/mgechev/revive@latest
go get -u -modfile=tools.mod tool
```

Same for listing the installed tools:

```bash
# instead of...
go tool

# we do...
go tool -modfile=tools.mod
```

> [!TIP]
> Can also try `go list -modfile=tools.mod tool`

To verify the integrity of the tool dependencies:

```
go mod verify -modfile=tools.mod
```

Here's an associated Makefile:

```Makefile
.PHONY: deps-app-update
deps-app-update: ## Update all application dependencies
    go get -u -t ./...
    go mod tidy
    if [ -d "vendor" ]; then go mod vendor; fi
    
.PHONY: deps-outdated
deps-outdated:  ## Lists direct dependencies that have a newer version available
    @go list -u -m -json all | go tool -modfile=tools.mod go-mod-outdated -update -direct
    
TOOLS = \
    cuelang.org/go/cmd/cue \
    github.com/client9/misspell/cmd/misspell \
    github.com/go-delve/delve/cmd/dlv \
    github.com/mgechev/revive \
    github.com/psampaz/go-mod-outdated \
    github.com/stealthrocket/wasi-go/cmd/wasirun \
    github.com/stern/stern \
    github.com/tetratelabs/wazero/cmd/wazero \
    golang.org/x/lint/golint \
    golang.org/x/tools/cmd/stringer \
    golang.org/x/tools/go/analysis/passes/nilness/cmd/nilness \
    golang.org/x/vuln/cmd/govulncheck \
    honnef.co/go/tools/cmd/staticcheck \
    mvdan.cc/gofumpt \

.PHONY: tools
tools:
    @$(foreach tool,$(TOOLS), \
        if ! go tool -modfile=tools.mod | grep "$(tool)" >/dev/null; then \
            go get -modfile=tools.mod -tool "$(tool)"@latest; \
        fi; \
    )

.PHONY: tools-update
tools-update:
    go get -u -modfile=tools.mod tool
    go mod tidy -modfile=tools.mod
```

> [!IMPORTANT]
> This approach keeps the main `go.mod` and `go.sum` clean of any tool dependencies, but not the other way around. So the `tools.mod` and `tools.sum` will ultimately contain all the dependencies from the main `go.mod` (that is a side-effect of running `go mod tidy -modfile=tools.mod` as `go mod` always consults the main `go.mod`, hence all of its dependencies end up in your `tools.mod` and `tools.sum`).
>
> This is unavoidable. There is no way to get around it (trust me, I've tried 😅).
>
> Now, this isn't the end of the world as the `tools` directive is still at the top of the `tools.mod` and is very clear as to what "tools" are installed, but yeah, you'll also see a bunch of `require` directives (related to your main Go project) as well, unfortunately.
>
> One thing you could do, is only run the `go get -u -modfile=tools.mod tool` command, which would keep your `tools.mod` clean, and would only update `tools.sum` with the relevant updated dependencies. The problem with that is the old dependencies aren't cleaned out. e.g. if you updated tool "foo" from version 1.0 to 2.0 then both versions appear in your `tools.sum` (this is why we have `go mod tidy` to ensure only 2.0 is present in the `tools.sum`). So one approach would simple be to manually clean up the `go.sum` everytime after running `go get -u -modfile=tools.mod tool` -- it's not that difficult as you just look for the new tool version added and remove the old one, but it's a manual process and that sucks).

## tools.go

> [!TIP]
> For more details on code generation in a general sense, refer to:\
> https://gist.github.com/Integralist/8f39eb897316e1cbeaf9eff8326cfa59

The following file `internal/tools/tools.go` uses a build tag to avoid the dependencies being compiled into your application binary...

```go
//go:build tools

// Package tools manages go-based tools that are used to develop in this repo.
package tools

import (
    _ "github.com/nbio/cart"
    _ "github.com/nbio/slugger"
    _ "github.com/psampaz/go-mod-outdated"
    _ "github.com/stealthrocket/wasi-go/cmd/wasirun"
    _ "github.com/tetratelabs/wazero/cmd/wazero"
    _ "golang.org/x/lint/golint"
    _ "golang.org/x/tools/cmd/stringer"
    _ "golang.org/x/vuln/cmd/govulncheck"
)

//go:generate go install github.com/nbio/cart
//go:generate go install github.com/nbio/slugger
//go:generate go install github.com/psampaz/go-mod-outdated
//go:generate go install github.com/stealthrocket/wasi-go/cmd/wasirun
//go:generate go install github.com/tetratelabs/wazero/cmd/wazero
//go:generate go install golang.org/x/lint/golint
//go:generate go install golang.org/x/vuln/cmd/govulncheck
//go:generate go install golang.org/x/tools/cmd/stringer
```

Notice the `go:generate` comments? Yup, we invoke them like so (notice the `-tags` flag):

```Makefile
tools: internal/tools/tools.go
    go generate -v -x -tags tools ./internal/tools/...
```

## go run

An alternative to this approach is to use `go run` directly, which downloads tools to a cache but doesn't install them and yet still gives you explicit versioning consistency across developer's machines...

```go
//go:generate go run golang.org/x/tools/cmd/stringer@v0.25.0 -type=Scope -linecomment
```

I then invoke go generation with:

```Makefile
.PHONY: go-gen
go-gen: ## Invoke go generate
    @# The `-x` flag prints the shell commands that `go generate` runs.
    go generate -v -x ./mustang/status/...
```

> [!TIP]
> If you're developing whilst offline, then one advantage the tools.go pattern
> has is that it works whilst offline because the tool is explicitly installed.
> But to work around that with `go run` you can set `export GOPROXY=direct` and
> as long as you have the module in your local cache you'll be able to use it.
