The Makefile tension
You clone a Go repository. The README says make build. You run it. It works. You open the Makefile. It contains go build ./.... You pause. You just typed a command to run a command that you could have typed directly. This is the moment every Go developer faces: the tension between familiar automation and the toolchain's built-in power.
Go was designed to eliminate build system friction. The go command is not just a compiler wrapper. It is a dependency manager, a cache, a test runner, and a binary installer. It tracks the state of your project using content hashes. Makefiles track state using file timestamps. These two models clash. You can use a Makefile in Go, but you need to understand what you are gaining and what you are losing before you write one.
How Go tracks state versus how Make does
A Makefile decides whether to run a target by comparing file modification times. If the source file is newer than the output file, Make runs the command. This works well for C or C++ projects where the build tool knows nothing about dependencies. The Makefile has to list every header file and source file manually.
Go takes a different approach. The go command computes a cryptographic hash of every input: source files, dependency versions, environment variables, and compiler flags. It stores the result in a build cache. When you run go build, Go checks the hash. If the hash matches a cached result, Go returns the cached binary instantly. If the hash differs, Go recompiles only what changed.
This difference creates subtle problems when you wrap go in a Makefile. Make checks timestamps. Go checks content. If you modify a file but the content remains identical, Make sees a timestamp change and rebuilds. Go sees the hash match and skips the work. Go is faster. If you update a dependency in go.mod, Make might not notice unless you explicitly list go.mod as a dependency. Go notices immediately and rebuilds. Go is safer.
The go toolchain is the source of truth for your build state. A Makefile that tries to duplicate that logic will always be one step behind.
The minimal Makefile
Here is the simplest Makefile for a Go project. It wraps the build command.
# Makefile
# .PHONY declares that 'build' is not a file name.
# Make always runs the target even if a file named 'build' exists.
.PHONY: build
# build compiles the main package in the cmd directory.
# The tab character before go is required by Make syntax.
build:
go build -o bin/myapp ./cmd/myapp
This Makefile adds no value over running go build -o bin/myapp ./cmd/myapp directly. It introduces a dependency on make, which may not be installed on all systems. It hides the actual command behind a target name. If a new contributor runs make and sees an error, they have to open the file to find the real command.
Use this pattern only when you have a reason to abstract the command. Otherwise, the direct go command is clearer and more portable.
Makefiles are optional. go is the default.
When a Makefile earns its place
A Makefile becomes useful when you need to orchestrate multiple tools that live outside the go toolchain. Go does not build Docker images. Go does not run third-party linters. Go does not generate protobuf code. A Makefile can glue these steps together into a single workflow.
Here is a realistic Makefile that handles testing, linting, and Docker builds. It uses Go for what Go does best and calls external tools for the rest.
# Makefile
# .PHONY lists targets that do not produce files.
# Make runs these targets every time they are invoked.
.PHONY: all test lint docker clean
# all is the default target.
# It runs the full check suite when the user types 'make'.
all: test lint
# test runs tests with race detection and generates a coverage profile.
# The ./... pattern tells Go to test all packages in the module.
test:
go test -race -coverprofile=coverage.out ./...
# lint runs the linter.
# golangci-lint is a separate tool, not part of the go command.
lint:
golangci-lint run ./...
# docker builds the binary and then creates the container image.
# go build creates the executable. docker build wraps it.
docker:
go build -o bin/myapp ./cmd/myapp
docker build -t myapp:latest .
# clean removes generated artifacts.
# go clean removes cached build data. rm removes local binaries.
clean:
go clean -cache
rm -rf bin/
This Makefile provides a single entry point for complex workflows. The all target runs checks. The docker target combines Go compilation with Docker packaging. The clean target resets the environment. This is a legitimate use of Make: coordination, not duplication.
Most editors run gofmt on save. Don't argue about indentation; let the tool decide. If your Makefile includes a fmt target, it is likely redundant. Trust the editor integration.
Pitfalls and errors
Makefiles introduce their own failure modes. The most common is the tab error. Make requires tabs before commands. Spaces cause a syntax error.
The Make parser rejects the file with
Makefile:5: *** missing separator. Stop.if you use spaces instead of tabs.
Another common error is missing targets. If you type a target that does not exist, Make fails immediately.
Make stops with
make: *** No rule to make target 'build'. Stop.when the target name is wrong or the Makefile is not in the current directory.
The wrapper anti-pattern is a design pitfall. Some projects wrap every go command in a Makefile target: make build, make test, make vet, make run. This creates friction. Contributors must learn the Makefile targets instead of using standard go commands. It breaks muscle memory. It adds a layer of indirection that obscures errors. If the Makefile fails, the error message points to the Makefile, not the Go code.
A Makefile should be a convenience, not a requirement. If a project requires make to build, it raises the barrier to entry. The go command works everywhere Go is installed. make does not.
The worst Makefile bug is the one that silently skips a rebuild. If dependencies are missing from the Makefile, Make might run an outdated command. Go's hash-based cache prevents this. When in doubt, let Go handle the build.
Don't fight the cache. go knows more about your code than make does.
Decision matrix
Use go build when you are compiling a package and want the fastest feedback loop with accurate dependency tracking.
Use go test when you are running tests and need race detection, coverage, or parallel execution built into the toolchain.
Use a Makefile when you need to orchestrate multiple tools like linters, formatters, or Docker that live outside the go toolchain.
Use a shell script when the logic involves complex branching, environment setup, or user interaction that Make's dependency graph cannot express.
Use CI/CD configuration when the workflow runs in a pipeline and needs to integrate with artifacts, notifications, and deployment steps.
Use go run when you are experimenting and don't want to manage a binary file on disk.
Use go install when you want to install a binary into your GOPATH/bin for global access.
Reach for go mod tidy when you need to clean up go.mod and go.sum after adding or removing dependencies.
Trust go. It caches better than you think.