Overview
Every project I've joined in the last five years has had a Makefile, and half of them were wrong. They were either a thin wrapper around npm run that added nothing, or a graveyard of targets from three developers ago that nobody dared delete. Neither is what Make is for.
Make is a task runner with a specific model: files with dependencies, rebuilt when a dependency changes. Once you use it that way, it's the right tool for a specific set of jobs and the wrong tool for a lot of others. This is what I've landed on.
What Make actually does
The core model is: target: dependencies followed by a command. If the target doesn't exist, or any dependency is newer than the target, run the command.
build/app: src/main.go
go build -o build/app ./src
Run make build/app. If build/app is newer than src/main.go, Make prints "up to date" and does nothing. If src/main.go is newer, it rebuilds. That's the whole feature, and it's genuinely useful for compiled languages, generated files, and build artifacts.
Where Make gets misused is when people use it as a generic script runner with no file dependencies at all:
.PHONY: test
test:
npm test
This is a Makefile as a list of aliases. It works, and I've written it many times, but it's worth knowing you're not using Make's actual feature. When every target is .PHONY, you're getting no more than npm scripts or a shell script with extra syntax.
Where Make genuinely earns its place
| Use case | Why Make fits |
|---|---|
| Compiled languages (Go, C, Rust) | Incremental rebuilds for free |
| Generated files (protobuf, codegen) | Only regenerate when source changes |
| Multi-language monorepos | One entry point for every toolchain |
| Documentation builds | Only rebuild changed pages |
| Asset pipelines | Image processing, minification |
For pure JavaScript projects, npm scripts or pnpm is usually enough. Make's advantage shows up when you have a Go service, a Python CLI, and a React app in the same repo, and you want one command that builds all three in the right order.
The three things you need to know
Tabs, not spaces
Recipe lines must start with a literal tab character. Not four spaces. Not a tab that your editor converted to spaces. A tab. This has been the #1 source of Makefile frustration since 1976, and it's not going away.
build:
echo "this works"
echo "this fails with 'missing separator'"
Configure your editor to show whitespace and refuse to expand tabs in Makefiles. It saves a lot of confusion.
.PHONY
Declare targets that aren't files:
.PHONY: build test clean all
build:
go build -o bin/app ./cmd/app
test:
go test ./...
clean:
rm -rf bin/
all: build test
Without .PHONY, a file named test in your project would make make test print "up to date" and do nothing. Declaring it phony tells Make to always run the recipe.
Variables and := vs =
APP_NAME = myapp # recursive: evaluated when used
VERSION := 1.0.0 # simple: evaluated immediately
BINARY := bin/$(APP_NAME)
build:
go build -o $(BINARY) -ldflags "-X main.version=$(VERSION)" ./cmd/app
:= is almost always what you want. = defers evaluation, which produces confusing results when a variable is defined later in the file.
A Makefile that works for a real project
# Detect the OS and use the right shell for each
SHELL := /bin/bash
# Project metadata
APP_NAME := myapp
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Directories
BUILD_DIR := build
BINARY := $(BUILD_DIR)/$(APP_NAME)
GO_SOURCES := $(shell find . -name '*.go' -not -path './vendor/*')
# Build flags
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
GOFLAGS := -trimpath
.PHONY: all build test lint clean fmt vet run docker
all: lint test build
# Real file target — incremental by default
$(BINARY): $(GO_SOURCES) go.mod go.sum
@mkdir -p $(BUILD_DIR)
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $@ ./cmd/$(APP_NAME)
build: $(BINARY)
test:
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1
lint:
golangci-lint run ./...
fmt:
gofmt -s -w $(GO_SOURCES)
vet:
go vet ./...
run: build
./$(BINARY)
docker:
docker build -t $(APP_NAME):$(VERSION) .
clean:
rm -rf $(BUILD_DIR) coverage.out
Points worth calling out:
- The binary target depends on the actual source files. Touch a Go file, run
make, it rebuilds. Touch nothing, runmake, it prints "up to date." - The version is baked in from Git. Every build knows its commit and tag, which makes debugging production issues much easier.
$@is the target name. Instead of retyping$(BINARY), use$@. Same value, less repetition.@prefix silences the command echo. Use it for commands whose output you don't care about, likemkdir.
Parallel execution
make -j4
Runs up to four targets in parallel. This only works if the targets have no hidden dependencies — if test and build both write to the same directory, running them in parallel corrupts state.
For a build pipeline, it's transformative. A build that takes 90 seconds sequentially runs in 25 seconds with -j4. For anything else, avoid it.
Wildcard patterns
# Compile every .go file matching a pattern
build/%.pb.go: proto/%.proto
protoc --go_out=build --go-grpc_out=build $<
# $< is the first dependency, $@ is the target
Pattern rules are what make Make genuinely different from a shell script. A single pattern rule handles every file matching the shape, and Make figures out the dependency graph automatically.
I use this for protobuf compilation. Adding a new .proto file requires zero Makefile changes — the pattern matches it.
Error handling
By default, Make stops at the first failing command in a recipe. To continue:
lint:
golangci-lint run ./... || true
To run a cleanup regardless of success or failure:
.ONESHELL:
test:
set -e
go test ./... || (echo "tests failed, cleaning up" && rm -f /tmp/test-artifacts; exit 1)
.ONESHELL makes each recipe run in a single shell invocation, which lets multi-line logic share state. Without it, each line is a separate shell, and variables don't persist.
When not to use Make
- Pure JavaScript project with no generated files.
npm scriptsorpnpmis simpler and has better cross-platform behavior. - Anything that needs to run identically on Windows. Make on Windows is possible but painful. Use
justortaskinstead. - Complex build logic with conditionals on many variables. Make's conditionals are awkward. A shell script with
casestatements is clearer. - When you just want a list of aliases. If every target is
.PHONYand none depend on files, use something purpose-built.
That last point is the one I'd emphasize. A Makefile that only contains phony targets is a Makefile in name only. It works, but you're paying the tab-vs-space cost and the $@/$< syntax cost for none of the incremental build benefit.
The rule I use
If a target produces a file that other targets depend on, it belongs in a Makefile with proper dependencies. If a target just runs a command, it belongs in whatever script runner your ecosystem already has. Mixing them is fine, but knowing which is which makes the Makefile easier to maintain.
