The Rule Model
Makefiles look simple and feel arbitrary. You see one in every professional repo. You run make build or make test. It works. Then you try to add your own target, you use four spaces instead of a tab, and you get *** missing separator. Stop. — a cryptic error from a tool designed in 1977 that still uses tab characters as its grammatical backbone.
Once you understand the rule model — targets, prerequisites, recipes, and the "is this file up to date?" check — Makefiles stop being arbitrary and start being logical. This lesson is the rule model, end to end.
A Makefile is a declarative dependency graph. You declare what outputs exist, what inputs they depend on, and how to build them. make walks the graph, rebuilds anything outdated, and skips anything still current. That is the whole idea.
The shape of a rule
A rule has three parts:
target: prerequisite1 prerequisite2
recipe line 1
recipe line 2
- Target: the thing being built (usually a filename).
- Prerequisites (deps): what it depends on. If any is newer than the target, rebuild.
- Recipe: the commands to run. Each line starts with a TAB character (not spaces).
A concrete example:
app: main.o utils.o
gcc -o app main.o utils.o
main.o: main.c
gcc -c main.c
utils.o: utils.c
gcc -c utils.c
Running make app:
- Make checks if
appexists and ifmain.oandutils.oare older thanapp. - If
main.ois missing or older thanmain.c, it rebuildsmain.ofirst. - Same for
utils.o. - Then it runs the recipe for
app.
On a second make app with no changes: make: 'app' is up to date. Nothing runs. That is the value proposition.
The tabs-vs-spaces rule
Recipe lines must start with a tab character, not spaces. This is the single most common reason Makefiles fail for new users.
# WRONG — spaces before gcc
build:
gcc main.c
# CORRECT — tab before gcc
build:
gcc main.c
Most editors hide the distinction. If you get *** missing separator, the answer is almost always "you have spaces where tabs belong."
How to insert a tab in your editor
- VS Code: "Makefile" file type automatically handles tabs. Or set
"editor.insertSpaces": falsefor.mk/Makefile. - Vim:
:set noexpandtabfor Makefiles. - Most editors: a
.editorconfigfile can enforce:
[Makefile]
indent_style = tab
The tab rule is the single silliest thing about Make. It exists because Make is 50 years old. Accept it; configure your editor; move on.
How make decides to rebuild
Make is file-timestamp based. A target is considered up-to-date if:
- The target file exists.
- Every prerequisite is older than the target.
If either is false, Make rebuilds the target.
This is what makes Make fast on large projects: it only rebuilds what changed.
.PHONY — targets that aren't files
A common use case: make test, make clean, make install. These aren't files. They're commands disguised as targets.
test:
go test ./...
clean:
rm -rf build/
Problem: if a file called test or clean exists in the directory, Make sees the target as "up to date" and does nothing.
Fix: declare them .PHONY:
.PHONY: test clean
test:
go test ./...
clean:
rm -rf build/
.PHONY tells Make "this target is not a file; always run it."
Every command-like target you add should be .PHONY. Most production Makefiles look like:
.PHONY: build test lint clean install
build:
...
test:
...
Put all your .PHONY targets in one .PHONY: build test lint clean ... line near the top of the Makefile. One source of truth.
Default target
Running make with no arguments runs the first target in the file. This is the "default target" by convention:
.PHONY: help
# First target -> this is what `make` with no args runs.
help:
@echo "targets: build test lint clean"
build:
...
test:
...
Many Makefiles put a help target first so bare make prints usage.
You can override with .DEFAULT_GOAL:
.DEFAULT_GOAL := build
Now make with no args builds, regardless of target order.
Echoing and silencing
By default, Make prints each recipe line before running it:
build:
echo building
go build ./...
Running make build:
echo building
building
go build ./...
Suppress the echo with @:
build:
@echo building
@go build ./...
building
Much cleaner. Use @ for any recipe line whose command is self-explanatory or noisy.
Running without echo entirely
make -s build # silent mode, suppresses all echoes
Continuing on error — - prefix
By default, if a recipe line fails (non-zero exit), Make aborts:
clean:
rm -rf build/
rm -rf dist/ # if build/ didn't exist and rm failed, this line doesn't run
Prefix with - to ignore errors:
clean:
-rm -rf build/
-rm -rf dist/
Now both run regardless. Useful in clean targets where missing files aren't a real failure.
Modern alternative: rm -rf already tolerates missing files (-f = force / no errors). The - prefix matters more for commands that actually might fail.
Phony alias targets
A common pattern: a short alias for a long target:
.PHONY: b build
b: build
build:
go build ./...
make b is a shorter alternative to make build. The b target has no recipe — it just depends on build.
Running multiple targets
make clean build test
Runs each in order. Useful in CI pipelines and one-off commands.
Pattern: self-documenting Makefile
You can make make help automatically list targets with comments. The idiomatic pattern:
.PHONY: help
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: build test clean
build: ## Build the application
go build ./...
test: ## Run all tests
go test ./...
clean: ## Remove build artifacts
rm -rf build/
Running make help:
build Build the application
test Run all tests
clean Remove build artifacts
Any target with a ## description comment appears automatically. Add new targets; help updates itself.
This is in every good Makefile. We'll cover more of it in the next lesson.
Order-only prerequisites
Sometimes a prerequisite should exist but shouldn't trigger a rebuild. For example, a build directory:
build/app: main.c | build
gcc -o $@ $<
build:
mkdir -p build
The | separates normal prerequisites from order-only prerequisites. build must exist before build/app can be made, but changes to the build directory (e.g. mtime) don't force a rebuild of build/app.
Without |, Make would rebuild every time the directory's mtime changed (which happens every time a file inside it is modified).
Automatic variables
Inside a recipe, Make provides several special variables:
$@— the target name$<— the first prerequisite$^— all prerequisites (space-separated)$?— prerequisites newer than the target$*— the stem (used in pattern rules)
Example:
%.o: %.c
gcc -c -o $@ $<
Reads: "to build any .o, use its .c as source. Output to $@ (the target), from $< (the first prereq)."
Replaces many hand-written rules. We'll cover pattern rules more in the next lesson.
What NOT to do in Makefiles
1. Don't use .PHONY for file targets
# WRONG — app is a file, not a command
.PHONY: app
app: main.o
gcc -o app main.o
This forces app to rebuild every time, defeating the cache.
2. Don't chain shell commands with separate lines if they need shared state
Each recipe line is a separate shell invocation. So:
run:
cd /tmp
pwd # prints your CWD, not /tmp
Chain them:
run:
cd /tmp && pwd
Or use a .ONESHELL: directive:
.ONESHELL:
run:
cd /tmp
pwd # now this is /tmp
.ONESHELL: makes all recipe lines in that target run in one shell invocation.
3. Don't put complex shell logic inline
# Hard to read
deploy:
if [ "$(ENV)" = "prod" ]; then \
echo "deploying to prod"; \
kubectl apply -f manifests/prod; \
else \
echo "deploying to dev"; \
kubectl apply -f manifests/dev; \
fi
Better: extract to a shell script.
deploy:
scripts/deploy.sh $(ENV)
Makefiles are for dependency graphs and trivial commands. Anything with branching belongs in a separate script.
The include directive
Split large Makefiles into parts:
# Main Makefile
include build.mk
include deploy.mk
include test.mk
Each file can contain its own rules and variables. Gets used in big monorepos.
Debugging a Makefile
make -n (dry run) shows what Make would run without running it:
make -n build
# gcc -c main.c
# gcc -c utils.c
# gcc -o app main.o utils.o
make -d (debug mode) shows how Make is walking the dependency graph:
make -d build 2>&1 | head -20
# Reading makefiles...
# Updating goal targets....
# Considering target file 'build'.
# ...
Noisy but useful for "why is this target rebuilding when I expect it not to?"
Full example
# Simple Go project Makefile
BINARY := myapp
GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*')
.PHONY: help build test lint clean install
.DEFAULT_GOAL := help
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
build: $(BINARY) ## Build the binary
$(BINARY): $(GO_FILES)
@echo "building $@"
@go build -o $@ .
test: ## Run tests
@go test ./...
lint: ## Run linters
@golangci-lint run
clean: ## Remove build artifacts
@rm -f $(BINARY)
@echo "cleaned"
install: $(BINARY) ## Install to /usr/local/bin
@install -m 0755 $(BINARY) /usr/local/bin/
Reads clean. Has a self-documenting help. Uses a file target for the binary (caches correctly). Uses phony targets for commands.
Quiz
You add a test target to your Makefile: test: go test ./.... You run make test and it works. Someone else on your team creates a file called test in the repo. Now make test says make: test is up to date. Why?
What to take away
- A Makefile is a dependency graph. Targets depend on prerequisites. Make rebuilds what's stale.
- Tab characters before recipe lines. Not spaces. Get your editor right.
.PHONYtargets are commands, not files. Declare them or Make will skip the recipe when a same-named file exists.@prefix silences the command echo. Use liberally for clean output.-prefix ignores recipe errors. Useful incleantargets.- Automatic variables:
$@(target),$<(first dep),$^(all deps),$*(stem). .DEFAULT_GOAL := helpto set the bare-makebehavior.- Pair
## commentat the end of each target with a help-generating awk line for a self-documenting Makefile. - Complex logic belongs in shell scripts, not Makefile recipes.
- Debug with
make -n(dry run) andmake -d(full trace).
Next lesson: variables, functions, and patterns — $(...) and $(shell ...) and the := vs = vs ?= distinction.