cheat sheet
make
Practical Makefile patterns: targets, prerequisites, automatic variables, pattern rules, parallel builds, and a working lint/test/build/clean workflow for any project.
make — Build Automation
What it is
make is the original Unix build automation tool, created by Stuart Feldman at Bell Labs in 1976. It reads a Makefile containing rules of the form target ← prerequisites ← recipe and rebuilds only the targets whose prerequisites are newer than the target itself — incremental builds for free. GNU Make (gmake) is the de-facto standard implementation found on every Linux distribution and macOS via Xcode Command Line Tools; the alternatives worth knowing are BSD bmake (slightly different syntax) and modern task runners like just or task which use TOML/YAML and skip the prerequisite-graph features. Reach for make whenever you want a single command (make, make test, make deploy) that anyone can run without learning a project-specific tool — and lean on its dependency tracking when you have files that need to be rebuilt only when their inputs change.
Install
make ships in every Linux base install group and in Apple's Command Line Tools; if it's missing, the package name is always either make or build-essential.
# Debian/Ubuntu
sudo apt install build-essential # gcc + make + libc-dev
# Fedora/RHEL
sudo dnf install make
# Alpine
sudo apk add make
# macOS — installs via Xcode CLT
xcode-select --install
Output: (none — exits 0 on success)
Syntax
A Makefile is a plain text file (usually named Makefile or makefile) of rules. Each rule has a single line declaring the target and its prerequisites, followed by one or more TAB-indented recipe lines that produce the target. Tabs — not spaces — are mandatory; this is the most common new-user pitfall.
target: prereq1 prereq2
<TAB>recipe-command-1
<TAB>recipe-command-2
Output: (none — exits 0 on success)
Essential command-line flags
| Flag | Meaning |
|---|---|
-f FILE | Use a different Makefile (default: Makefile) |
-C DIR | cd DIR before reading the Makefile |
-j [N] | Run up to N recipes in parallel (no N → unlimited) |
-n / --dry-run | Print what would run without executing |
-B / --always-make | Force rebuild — ignore timestamps |
-k | Keep going after a recipe fails |
-s | Silent — don't echo recipe lines |
-d | Debug output |
-p | Print all rules and variables, then run normally |
--warn-undefined-variables | Flag any $(foo) where foo is unset |
Targets, prerequisites and recipes
A target is the name of a file that make will produce, and prerequisites are the files (or other targets) it depends on. When you run make <target>, make checks each prerequisite recursively: if any prerequisite is newer than the target, the recipe re-runs. This timestamp comparison is the engine that turns make from "shell-script-with-extra-steps" into a true incremental build system.
# Build hello from hello.c
hello: hello.c
gcc -o hello hello.c
# Multi-step: object file then link
hello: hello.o utils.o
gcc -o hello hello.o utils.o
hello.o: hello.c utils.h
gcc -c hello.c -o hello.o
utils.o: utils.c utils.h
gcc -c utils.c -o utils.o
Output (make hello):
gcc -c hello.c -o hello.o
gcc -c utils.c -o utils.o
gcc -o hello hello.o utils.o
Run again with no source changes — make does nothing:
make hello
Output:
make: 'hello' is up to date.
.PHONY targets
A phony target is one that doesn't correspond to a real file on disk — for example test, clean, or install. Marking these with .PHONY: is essential because otherwise make will skip the recipe whenever a file of the same name happens to exist alongside the Makefile, and the failure mode (silent no-op) is confusing.
.PHONY: clean test all install
all: hello
test:
pytest -v
clean:
rm -f hello *.o
install: hello
install -m 0755 hello /usr/local/bin/
Output (make clean):
rm -f hello *.o
Without .PHONY and if a file called clean exists:
Output:
make: 'clean' is up to date.
Automatic variables
Inside a recipe, GNU Make exposes a handful of single-character variables that refer to parts of the current rule. They're indispensable for writing rules that work for many inputs without repetition. The names look cryptic; the table below covers everything you'll use in practice.
| Variable | Meaning |
|---|---|
$@ | Target name (full) |
$< | First prerequisite |
$^ | All prerequisites (space-separated, deduplicated) |
$+ | All prerequisites (with duplicates, original order) |
$? | Prerequisites newer than the target |
$* | The stem (the % part) in a pattern rule |
$(@D) | Directory part of $@ |
$(@F) | File part of $@ |
$(<D) $(<F) | Same for $< |
hello: hello.c utils.c
gcc -o $@ $^
Output (make hello):
gcc -o hello hello.c utils.c
Pattern rules
Pattern rules use % as a wildcard to define a single recipe that applies to any file matching a pattern. %.o: %.c is the canonical example: "for any .o file, the prerequisite is the .c file of the same name." This is what makes Makefiles scale beyond a handful of hard-coded rules.
CC := gcc
CFLAGS := -Wall -O2 -std=c11
# Any .o is built from the matching .c
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Link all object files into the binary
hello: hello.o utils.o net.o
$(CC) $^ -o $@
Output (make hello):
gcc -Wall -O2 -std=c11 -c hello.c -o hello.o
gcc -Wall -O2 -std=c11 -c utils.c -o utils.o
gcc -Wall -O2 -std=c11 -c net.c -o net.o
gcc hello.o utils.o net.o -o hello
Pattern rules also work for non-C tasks — anything with a 1:1 mapping between input and output files:
# Minify each .css → .min.css
%.min.css: %.css
csso $< -o $@
# Convert each .md → .html via pandoc
%.html: %.md template.html
pandoc -o $@ --template=template.html $<
Output: (none — exits 0 on success)
Variables
Make has four assignment operators with subtly different semantics. Picking the right one is the single largest source of "why is my Makefile so slow?" surprises.
| Operator | Name | Behaviour |
|---|---|---|
= | Recursive | Right-hand side is re-expanded on every reference |
:= | Simple / immediate | Right-hand side is expanded once, when the line is read |
?= | Conditional | Assign only if the variable is currently unset |
+= | Append | Append to the existing value (recursive or simple, matching the original) |
::= | Simple (POSIX 2024) | Same as :=, POSIX-standardised form |
# Recursive — $(date) re-runs every time TODAY is used
TODAY = $(shell date +%F)
# Simple — captured at parse time, fixed forever
BUILT := $(shell date +%F)
# Conditional — caller can override on the command line
PREFIX ?= /usr/local
# Append
CFLAGS := -Wall
CFLAGS += -O2 -g
# Override from the command line
# make CFLAGS="-Wall -O0 -g3" hello
.PHONY: show
show:
@echo "TODAY = $(TODAY)"
@echo "BUILT = $(BUILT)"
@echo "PREFIX = $(PREFIX)"
@echo "CFLAGS = $(CFLAGS)"
Output (make show PREFIX=/opt/local):
TODAY = 2026-05-24
BUILT = 2026-05-24
PREFIX = /opt/local
CFLAGS = -Wall -O2 -g
Prefer
:=for everything unless you specifically need lazy evaluation. The recursive default can silently re-run an expensive$(shell …)on every reference and dominate build time.
Functions
GNU Make ships with built-in functions that mostly look like $(name args). The ones you'll reach for are file-globbing and string-manipulation helpers; together they let you write zero-config rules that adapt to whatever's on disk.
| Function | Use |
|---|---|
$(wildcard PATTERN) | Expand shell glob (e.g. $(wildcard src/*.c)) |
$(patsubst PAT,REPL,LIST) | Pattern substitution |
$(subst FROM,TO,STR) | Plain string substitution |
$(filter PAT,LIST) | Keep words matching pattern |
$(filter-out PAT,LIST) | Drop words matching pattern |
$(notdir NAME) / $(dir NAME) | Path split |
$(basename NAME) / $(suffix NAME) | Strip / extract extension |
$(shell CMD) | Run a shell command, return its stdout |
$(addprefix P,LIST) | Prepend P to each word |
$(strip TEXT) | Trim & collapse whitespace |
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)
build/%.o: src/%.c | build
$(CC) -MMD -MP $(CFLAGS) -c $< -o $@
build:
mkdir -p $@
hello: $(OBJS)
$(CC) $^ -o $@
-include $(DEPS)
Output (make hello):
mkdir -p build
gcc -MMD -MP -Wall -O2 -c src/hello.c -o build/hello.o
gcc -MMD -MP -Wall -O2 -c src/utils.c -o build/utils.o
gcc build/hello.o build/utils.o -o hello
The -MMD -MP flags tell gcc to emit a .d file describing each source's header dependencies; -include $(DEPS) re-injects them so that editing utils.h automatically rebuilds every .o that included it.
Order-only prerequisites
A normal prerequisite is checked by timestamp — if it changes, the target rebuilds. An order-only prerequisite (everything after |) is checked only for existence: make ensures it exists before running the recipe, but won't rebuild the target if it changes. This is the right tool for "the build/ directory must exist" relationships.
$(OBJS): | build
build:
mkdir -p build
Output: (none — exits 0 on success)
Multiple targets and double-colon rules
A rule may have multiple targets, in which case the recipe runs once per target. Double-colon rules (::) allow the same target to appear in multiple independent rules — useful for hook patterns where several modules each add steps to a global install:: target.
# Same recipe for two outputs
docs.html man.html: template.html
pandoc --template=$< $(basename $@).md -o $@
# Double-colon: each block runs independently
install:: bin/hello
install -m 0755 $< $(PREFIX)/bin/
install:: share/hello.cfg
install -m 0644 $< $(PREFIX)/etc/hello/
Output: (none — exits 0 on success)
Conditionals
Conditionals run at parse time and let a Makefile branch on variables, environment, or platform. Indentation inside a conditional is meaningful only inside recipes; the ifeq / endif lines must start at column 0.
UNAME := $(shell uname -s)
ifeq ($(UNAME),Darwin)
CC := clang
CFLAGS += -Wno-deprecated-declarations
else ifeq ($(UNAME),Linux)
CC := gcc
CFLAGS += -fstack-protector-strong
endif
ifdef DEBUG
CFLAGS += -O0 -g3 -DDEBUG
else
CFLAGS += -O2 -DNDEBUG
endif
Output (make CC?= show): (none — exits 0 on success)
Recipe shell behaviour
Each recipe line runs in its own subshell unless joined with \, so cd foo on one line does not persist to the next. Recipes default to /bin/sh; override with SHELL := /bin/bash if you need bashisms. A line prefixed with @ suppresses the echo, - ignores errors, and + forces execution even under -n (dry-run).
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c # fail fast: -e, undefined vars: -u, propagate pipe errors
.PHONY: chained no-cd correct-cd loud quiet
no-cd:
cd /tmp
pwd # still your project dir!
correct-cd:
cd /tmp && pwd # one shell, works
loud:
echo "this line is echoed first"
quiet:
@echo "this line is not echoed"
Output (make no-cd):
cd /tmp
pwd
/home/alice/project
Output (make correct-cd):
cd /tmp && pwd
/tmp
Parallel builds
make -j N runs up to N independent recipes simultaneously. Targets are scheduled according to the dependency graph, so parallelism is safe as long as your rules genuinely declare every input they read. -j alone (no number) gives unlimited parallelism — fine for fast builds, dangerous on memory-constrained machines.
make -j8 hello # 8 parallel jobs
make -j$(nproc) # one per CPU on Linux
make -j$(sysctl -n hw.ncpu) # one per CPU on macOS
make -j --output-sync=target hello # interleave-free output
Output: (none — exits 0 on success)
If your build breaks under
-jbut works without it, you have an undeclared dependency. The fix is always to declare it — never to remove-j.
Dry run and debugging
-n prints recipes without executing — invaluable when reviewing a destructive make clean or make deploy before pulling the trigger. -d is a firehose of dependency-graph reasoning useful when "why did this rebuild?" needs an answer.
make -n install # show what install would do
make -d hello | grep -i prerequisite # why is hello rebuilding?
make -p | head -50 # dump all rules and variables
Output (make -n clean):
rm -f hello *.o build/*.o build/*.d
Variable overrides and the environment
Variables on the make command line override anything in the Makefile, including := assignments — useful for parameterising builds without editing files. Environment variables are imported automatically but can be shadowed by Makefile assignments; pass -e to flip the precedence.
make CFLAGS="-Wall -O0 -g3" hello # override CFLAGS for this run
make -e CFLAGS="-Wall -O0 -g3" hello # environment wins over Makefile
PREFIX=/opt/local make install # set via env
Output: (none — exits 0 on success)
GNU Make 4.4+ features
GNU Make 4.4 (October 2022) and the 4.4.1 patch release (February 2023) added the first meaningful language features in years. Most distributions now ship 4.4.x — make --version confirms — and the additions below are worth knowing even if your Makefile still has to work on older releases.
| Feature | What it does |
|---|---|
--shuffle[=MODE] | Reorder goals and prerequisites to fuzz-test parallel builds. MODE is random, reverse, or a fixed seed. |
.NOTINTERMEDIATE | Mark targets so make never deletes them as "intermediate" after a chained pattern rule. Opposite of .INTERMEDIATE. |
.WAIT | Pseudo-prerequisite that forces serialisation between two groups in the same rule under -j. |
$(let VAR...,VAL...,BODY) | Bind local variables inside a function body — finally, lexical scope. |
$(intcmp LHS,RHS,LT,EQ,GT) | Numeric three-way comparison that expands to one of three branches. |
--jobserver-style=fifo | New jobserver using a named pipe (mkfifo) instead of an anonymous pipe; fixes lost-token bugs in deep recursive builds. Force the old style with --jobserver-style=pipe. |
::= | POSIX-2024 alias for := (simple/immediate assignment). |
?!= | Conditional shell assignment — like != but only if the variable is unset. |
# Fuzz-test the dependency graph by randomising scheduling order
# (run a few times — any missing prereq will eventually crash a -j build).
.PHONY: fuzz
fuzz:
$(MAKE) --shuffle clean
$(MAKE) --shuffle=random -j$(shell nproc) all
# .WAIT inside a single rule — generate code, THEN compile, THEN link.
all: generate .WAIT compile .WAIT link
# .NOTINTERMEDIATE — keep build/*.o around even though they're produced
# by a chained pattern rule (src/%.c -> build/%.o -> hello).
.NOTINTERMEDIATE: build/%.o
# $(let) — local bindings inside a recipe-evaluating function
PREFIXED = $(let dir name,$(dir $1) $(notdir $1),$(dir)build_$(name))
# $(intcmp) — pick an optimisation level from a numeric CPU count
NPROC := $(shell nproc)
JOBS := $(intcmp $(NPROC),4,2,4,$(NPROC))
Output (make --shuffle=random -j8 all):
gcc -c src/utils.c -o build/utils.o
gcc -c src/hello.c -o build/hello.o
gcc build/hello.o build/utils.o -o hello
--shuffle=randomis the easiest way to find latent under-declared dependencies in a parallel build. Add afuzztarget to CI and run it on a schedule.
If a recursive build hangs or reports
jobserver unavailable: using -j1, try--jobserver-style=fifo(4.4+) — the new named-pipe jobserver survives sub-make environments that the legacy pipe-based jobserver can't reach.
Modern alternatives
make is universal and battle-tested, but a wave of single-purpose task runners has emerged for projects that don't need timestamp-based dependency tracking. They're not drop-in replacements — they trade away incremental rebuilds for friendlier syntax, real arguments, and cross-platform behaviour.
| Tool | Language | Config | Sweet spot |
|---|---|---|---|
just | Rust | justfile | Project task runner — recipes take named arguments, just --list is auto-help, no TAB requirement, runs on Linux/macOS/Windows. |
task (go-task) | Go | Taskfile.yml | YAML-based, native includes for monorepos, hash-based change detection (not timestamps), built-in watch mode. |
mage | Go | magefile.go | Targets are plain Go functions — full IDE support, strong typing, ideal when your project is already Go. |
ninja | C++ | build.ninja (generated) | Pure speed for huge graphs (Chromium, LLVM). Not hand-written — emitted by CMake / Meson / gn. The make replacement when the dependency graph has 100k+ nodes. |
bazel / buck2 | Java / Rust | BUILD files | Hermetic, content-addressed, cache-shared builds at monorepo scale. Big upfront cost; pays off above ~500 engineers. |
# just — Rust-style command runner
cat > justfile <<'EOF'
default: lint test build
lint:
cargo clippy --all-targets
test pattern="":
cargo test {{pattern}}
deploy env="staging":
@echo "deploying to {{env}}"
./scripts/deploy.sh {{env}}
EOF
just # runs the default
just test integration # arg propagates to cargo test
just deploy production # named-arg recipe
Output (just --list):
Available recipes:
default
deploy env="staging"
lint
test pattern=""
# Taskfile.yml — go-task with hash-based change detection
version: '3'
tasks:
build:
cmds:
- go build -o bin/myapp ./cmd/myapp
sources:
- "**/*.go"
generates:
- bin/myapp
method: checksum # rebuild only if file hashes change
test:
deps: [build]
cmds:
- go test ./...
Output (task build):
task: [build] go build -o bin/myapp ./cmd/myapp
Pick
justif you want the closest "Makefile but nicer" feel without dependency-graph features. Picktaskif you want YAML, monorepo includes, and hash-based change detection. Pickninjaonly when CMake / Meson is generating it for you. Keepmakewhen you need ubiquity (every Unix has it), POSIX-portable timestamp-based incremental builds, or a single command newcomers already know.
Common pitfalls
- Tabs vs spaces — recipe lines must begin with a literal tab. Editors that auto-convert tabs silently break the build with
*** missing separator. Stop. .PHONYomitted —make cleanwill appear to do nothing if a file namedcleanhappens to exist. Always declare phony targets.- Each line is a fresh shell —
cd dir; ./toolon two lines does not work. Use&&and\to keep one shell, or use adefineblock. - Recursive
=on$(shell …)— runningdate(or worse, agit log) on every reference can dominate build time. Default to:=. - Globs at the wrong layer —
OBJS := *.ostores the literal string*.o, not the matching files. Use$(wildcard *.o)instead. - Spaces in paths — make can't reliably handle paths containing spaces. Stick to portable filenames; use
\escape only when you must. - Recursive make harms parallelism —
make -C subdirlaunches an independent build that doesn't share the job server. Prefer non-recursive ("single Makefile") setups for any build over 10s of targets — see Peter Miller's classic "Recursive Make Considered Harmful." - Silent failures with
-prefix —-cmdignores the exit status. Never use it on commands whose failure should stop the build; reserve it for cleanup steps where errors don't matter. - Missing
pipefail— without.SHELLFLAGS := -o pipefail -c, a failing left side of a pipe (./build | tee log) won't fail the recipe. Set it once at the top of every Makefile.
Real-world recipes
A complete project Makefile
A typical "lint + test + build + clean" workflow that's drop-in for any small project. Phony targets at the top, real targets with pattern rules below, a self-documenting help at the bottom.
.DEFAULT_GOAL := help
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
PROJECT := myapp
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
PREFIX ?= /usr/local
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)
CC := gcc
CFLAGS := -Wall -Wextra -O2 -std=c11 -MMD -MP
LDFLAGS :=
LDLIBS :=
.PHONY: help all build test lint fmt clean install run watch
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
all: build ## Build everything
build: $(PROJECT) ## Compile the binary
$(PROJECT): $(OBJS)
$(CC) $(LDFLAGS) $^ -o $@ $(LDLIBS)
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
build:
mkdir -p $@
test: build ## Run the test suite
./test-runner
lint: ## Static analysis
clang-tidy $(SRCS) -- $(CFLAGS)
fmt: ## Format source files
clang-format -i $(SRCS) $(wildcard src/*.h)
run: build ## Run the binary
./$(PROJECT)
watch: ## Rebuild and run on file changes
find src -name '*.c' -o -name '*.h' | entr -r make run
install: build ## Install to $(PREFIX)/bin
install -m 0755 $(PROJECT) $(PREFIX)/bin/
clean: ## Remove all build artefacts
rm -rf build $(PROJECT)
-include $(DEPS)
Output (make help):
help Show this help
all Build everything
build Compile the binary
test Run the test suite
lint Static analysis
fmt Format source files
run Run the binary
watch Rebuild and run on file changes
install Install to /usr/local/bin
clean Remove all build artefacts
Generic "lint + test + build" for a Node project
make is useful even when the language has its own task runner — it gives one entry point per machine, hides incantations, and chains steps in CI.
.PHONY: install lint test build clean
install:
npm ci
lint:
npm run lint
test: lint
npm test -- --coverage
build: test
npm run build
clean:
rm -rf node_modules dist coverage
Output (make build):
npm run lint
npm test -- --coverage
npm run build
Auto-generate header dependencies
The -MMD -MP trick is the smallest correct solution to header dependency tracking in C/C++. Without it, editing a .h won't force its dependents to recompile.
CFLAGS += -MMD -MP
DEPS := $(OBJS:.o=.d)
-include $(DEPS)
Output: (none — exits 0 on success)
Time the build
Quick instrumentation for finding the slow part of a multi-step pipeline.
make clean
/usr/bin/time -p make -j8 2>&1 | tail
Output:
real 12.34
user 81.20
sys 4.05
Self-documenting Makefile
The awk one-liner pulls ## comment text from every target into a help screen — no separate docs file required.
.PHONY: help
help:
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
deploy: ## Deploy to production
./scripts/deploy.sh
Output (make help):
help (no description)
deploy Deploy to production
CI-friendly variants
CI runners need silent, deterministic output and a non-zero exit on any failure. The recipe below combines --no-print-directory, -s, and strict shell flags.
.PHONY: ci
ci: SHELL := /bin/bash
ci: .SHELLFLAGS := -eu -o pipefail -c
ci:
@$(MAKE) --no-print-directory -s lint test build
Output: (none — exits 0 on success)
Tips
makeis the lowest-common-denominator task runner. Even on JavaScript or Python projects where the language has its own runner, a 10-lineMakefilewithinstall,lint,test,build, andcleanmakes the project trivially discoverable:make helpalways works.
When debugging "why did this rebuild?" run
make -d <target> 2>&1 | grep -E "remake|prerequisite" | head -40. It's verbose but cuts straight to the dependency reasoning.
If your Makefile gets complex, run
make -p > /tmp/db.mkto dump make's full internal database — every variable, every rule, every implicit recipe. It's the fastest way to discover that, say, GNU Make already has a built-in rule for your file extension.
Sources
- GNU Make 4.4 release announcement
- GNU Make 4.4 released — LWN.net
- GNU Make 4.4 — Phoronix coverage of new features
- GNU Make 4.4.1 release announcement
- POSIX Jobserver — GNU Make manual (
--jobserver-style=fifo) just— command runner (homepage and manual)casey/just— GitHub repositorytask(go-task) — Taskfile documentationmage— Go-based build toolninja— small, fast build system- Just Make a Task — Make vs. Taskfile vs. Just (Applied Go)