Makefiles Explained: Tabs, Targets, and Why make Is Still Useful
make builds things. It reads a Makefile, works out which targets are out of date, and runs commands to bring them up to date.
The dependency logic is genuinely useful, and it is also perfectly reasonable to use make as a task runner for a project that compiles nothing at all.
The basic shape
target: prerequisites
recipe
The recipe line starts with a literal tab. Not spaces. This is the most common Makefile error by a wide margin:
Makefile:4: *** missing separator. Stop.
That message always means spaces where a tab belongs. Configure your editor:
# .editorconfig
[Makefile]
indent_style = tab
In vim, :set noexpandtab for Makefiles. In nano, make sure set tabstospaces is not applying.
The tab requirement is a design mistake from 1976 that could not be fixed without breaking every existing Makefile. Its author has said as much publicly.
How it decides what to run
make compares modification times. If any prerequisite is newer than the target, the recipe runs.
app: main.o util.o
gcc -o app main.o util.o
main.o: main.c util.h
gcc -c main.c
util.o: util.c util.h
gcc -c util.c
Edit util.h and make rebuilds both object files and relinks. Edit main.c and only main.o rebuilds. That selectivity is the whole point.
The weakness is that mtime is a proxy for “changed”. touch a file and make rebuilds even though nothing changed. Clock skew across a network filesystem can cause missed rebuilds, which produces genuinely confusing bugs.
.PHONY
.PHONY: clean test install
clean:
rm -rf build/
test:
pytest tests/
Without .PHONY, a target named clean breaks the moment a file called clean exists in the directory. make sees the file, finds no prerequisites newer than it, and reports make: 'clean' is up to date.
Declare every target that is a command rather than a file. Forgetting is a bug waiting for someone to create an unrelated file.
Variables
CC := gcc
CFLAGS := -Wall -Wextra -O2
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)
app: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^
:= versus = matters.
:= is simply expanded: evaluated once, at assignment.
= is recursively expanded: re-evaluated every time it is used. So this loops forever:
CFLAGS = $(CFLAGS) -O2 # infinite recursion
CFLAGS := $(CFLAGS) -O2 # fine
And this runs the shell command on every reference rather than once:
DATE = $(shell date) # different value each use
DATE := $(shell date) # evaluated once
Use := unless you specifically want deferred evaluation.
Automatic variables are worth memorising:
| Variable | Means |
|---|---|
$@ | The target |
$< | The first prerequisite |
$^ | All prerequisites, deduplicated |
$? | Prerequisites newer than the target |
$* | The stem in a pattern rule |
Pattern rules
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
One rule for every .c to .o conversion. $< is the source, $@ is the object.
Each line is its own shell
This catches everyone once:
broken:
cd /tmp
pwd # prints the original directory, not /tmp
Every recipe line runs in a separate shell process. The cd affects a shell that has already exited.
works:
cd /tmp && pwd
also-works:
cd /tmp; \
pwd
Or set .ONESHELL so each recipe runs in one shell:
.ONESHELL:
works-too:
cd /tmp
pwd
make as a task runner
This is what most people actually want it for now, and it works well.
.PHONY: help build up down logs test fmt deploy
.DEFAULT_GOAL := help
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
build: ## Build the container image
docker compose build
up: ## Start services
docker compose up -d
down: ## Stop services
docker compose down
logs: ## Follow logs
docker compose logs -f
test: ## Run the test suite
docker compose run --rm app pytest
fmt: ## Format code
ruff format .
make # prints the help
make up
make test
That self-documenting help target is worth copying. It greps the Makefile for targets with ## comments and formats them, so the Makefile documents itself and make with no arguments tells a newcomer what they can do.
The @ prefix suppresses echoing the command itself.
The argument for make over a shell script or a task runner like just: it is on every Unix system already, it needs no installation, and everyone recognises make test. You give up dependency tracking, which you were not using anyway for docker compose up.
Useful flags
make -n target # dry run, print commands without running
make -j8 # parallel, 8 jobs
make -B target # force rebuild regardless of timestamps
make -C subdir # run in another directory
make --debug=b # why did it decide to rebuild this
make -j is a real speedup on a multi-core machine, provided the Makefile’s dependencies are correct. Incorrect dependencies produce race conditions that only appear under -j, which is a good test of whether the file is right.
make -n before anything destructive.
When not to use make
A project in a language with its own build tool. Cargo, Go’s toolchain, and npm scripts already handle this and understand the language. A Makefile wrapping them adds a layer.
Complex conditional logic. GNU make’s conditionals and functions exist and are painful. Past a certain complexity, a shell script or a real program is clearer.
Cross-platform builds. CMake and Meson exist for this reason. Our building from source guide covers what a typical configure-and-make sequence is doing.
For a small C project, a set of shell tasks, or a repository where you want one obvious entry point, a twenty-line Makefile is hard to beat.
Frequently Asked Questions
Why does my Makefile say missing separator?
A recipe line begins with spaces instead of a tab. make requires a literal tab character at the start of every command line, and this is the single most common Makefile error. Configure your editor to preserve tabs in Makefiles rather than expanding them to spaces.
What does .PHONY do in a Makefile?
It declares that a target name is not a real file. Without it, a target named clean will not run if a file called clean exists in the directory, because make sees the file as up to date. Declaring .PHONY makes the recipe run unconditionally.
How does make know what needs rebuilding?
It compares file modification times. If any prerequisite is newer than the target, the recipe runs. This is fast and has a known weakness: a file touched without changing content triggers a rebuild, and a clock skew across machines can cause missed rebuilds.
What is the difference between = and := in a Makefile?
Equals is recursively expanded, meaning the value is re-evaluated every time the variable is used. Colon-equals is simply expanded and evaluated once at the point of assignment. Prefer colon-equals in most cases, because recursive expansion can produce surprising results and repeated shell calls.
Can I use make for projects that do not compile anything?
Yes, and many people do. make works well as a task runner for Docker commands, test suites, deployment steps, and database migrations. You lose the dependency tracking that makes it clever, and you gain a self-documenting entry point that works on any Unix system with no extra tooling.
Why does make run commands in separate shells?
Each line of a recipe runs in its own shell process, so a cd on one line does not affect the next. Join the lines with backslashes and semicolons to run them in one shell, or set .ONESHELL to make every recipe run as a single shell invocation.