What Is a Makefile? Build Automation Explained
A Makefile defines targets, dependencies, and shell commands so `make` only rebuilds what changed. Here's how the rule syntax and dependency graph work.
A Makefile is a plain-text file that tells the make build tool what to build, what each thing depends on, and what shell commands to run to build it. make reads the file, figures out which pieces are out of date relative to their dependencies, and runs only the commands needed to bring the outdated pieces up to date — skipping anything that’s already current.
The core idea: targets, dependencies, recipes
A Makefile is built out of rules, each with three parts:
target: dependencies
command
- Target — the name of the thing being built, typically a file, though it can also be a label for an action with no file output (a “phony” target).
- Dependencies — other targets or files this one relies on. If any dependency is newer than the target, the target is considered out of date.
- Recipe — one or more shell commands, each indented with a tab (not spaces — this trips up nearly everyone the first time), that produce the target when run.
A minimal example for compiling a C program:
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 checks whether app is older than main.o or utils.o. If either object file doesn’t exist or is newer than app, make runs the link command. But first it checks whether main.o and utils.o themselves are up to date relative to their .c sources, recursively — rebuilding only the pieces that actually changed. Change one source file and make recompiles just that file and re-links, rather than rebuilding everything from scratch.
Why the dependency graph matters
This is the entire value proposition of make: it turns a build into a dependency graph instead of a linear script. A shell script that runs the same three compile commands every time has no way to know that utils.c didn’t change, so it recompiles everything regardless. make compares file modification timestamps across the graph and skips anything whose inputs haven’t changed since the last build. For small projects this saves seconds; for large codebases with expensive compilation or bundling steps, it’s the difference between a build that takes minutes and one that takes an hour.
Phony targets
Not every target corresponds to a file on disk. A target like clean that deletes build artifacts, or test that runs a test suite, doesn’t produce a file called clean or test — but if a file with that name ever existed in the directory, make would (incorrectly) think the target were already “built” and skip it. Declaring these as .PHONY tells make to always run them regardless of whether a same-named file exists:
.PHONY: clean test
clean:
rm -rf build/
test:
pytest
This is the pattern behind the familiar make build, make test, make lint commands that show up across countless projects, regardless of what language or build system sits underneath — a Makefile often works as a thin, consistent command layer over whatever npm scripts, Python tooling, or Docker commands a project actually uses.
Variables and pattern rules
Writing a separate rule for every source file doesn’t scale, so Makefiles support variables and pattern rules to generalize:
CC = gcc
CFLAGS = -Wall -O2
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
The %.o: %.c pattern rule matches any .o target against a same-named .c file, and $< (the first dependency) and $@ (the target name) are automatic variables that let one rule stand in for what would otherwise be a rule per file. This is how real-world Makefiles stay manageable even with hundreds of source files.
Where Makefiles fit today
make predates almost every other build tool in common use, and in ecosystems with their own native tooling — npm scripts for JavaScript, Cargo for Rust, Go’s build command — a Makefile is often optional rather than load-bearing. Where it still earns a place in modern projects is as a uniform entry point across a polyglot codebase: a repository that mixes a JavaScript frontend, a Python backend, and infrastructure config can use one Makefile to expose consistent make dev, make test, and make deploy targets, hiding the different tools underneath behind a common interface. It’s also a natural fit for CI pipelines, which can call the same make targets a developer runs locally, keeping local and CI behavior in sync instead of drifting into two separate sets of commands.
Makefile pitfalls worth knowing
A few sharp edges account for most of the confusion new users run into:
- Tabs, not spaces. Recipe lines must be indented with a literal tab character. A recipe indented with spaces produces a cryptic “missing separator” error.
- Timestamps, not content.
makecompares file modification times, not file contents. Touching a file (updating its timestamp without changing its content) will trigger an unnecessary rebuild; conversely, some workflows deliberately touch files to force one. - Recipes run in a fresh shell per line. Each line of a recipe runs in its own subshell by default, so
cd some-diron one line has no effect on the next line — multi-step recipes need to chain commands on one line with&&or use.ONESHELL.
The takeaway
A Makefile describes a build as a graph of targets, their dependencies, and the commands that produce them, letting make rebuild only what’s actually out of date rather than redoing everything on every run. That dependency-aware, incremental model is what has kept make relevant for decades, even in ecosystems with newer, language-specific build tools — often not as the primary build system, but as a thin, consistent command layer that ties a project’s different tools together behind a handful of memorable targets.
Tagged
Keep reading
Chisato · · 5 min read What Are Reproducible Builds? Verifiable Software, Explained
Reproducible builds produce bit-for-bit identical output from the same source, so anyone can verify a binary. Why they matter and how to achieve them.
Chisato · · 5 min read Pulumi vs Terraform: Code vs Declarative Config for IaC
Pulumi defines infrastructure in general-purpose languages like TypeScript and Python; Terraform uses its own declarative HCL. Here's how they differ.
The Lycoris Team · · 4 min read Deployment Rollback Strategies: Roll Back vs Forward
Rolling back reverts to the last known-good deploy; rolling forward ships a fix on top of the bad one. How to choose, and why database changes complicate both.