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.

bash
# 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.

makefile
target: prereq1 prereq2
<TAB>recipe-command-1
<TAB>recipe-command-2

Output: (none — exits 0 on success)

Essential command-line flags

FlagMeaning
-f FILEUse a different Makefile (default: Makefile)
-C DIRcd DIR before reading the Makefile
-j [N]Run up to N recipes in parallel (no N → unlimited)
-n / --dry-runPrint what would run without executing
-B / --always-makeForce rebuild — ignore timestamps
-kKeep going after a recipe fails
-sSilent — don't echo recipe lines
-dDebug output
-pPrint all rules and variables, then run normally
--warn-undefined-variablesFlag 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.

makefile
# 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):

text
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:

bash
make hello

Output:

text
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.

makefile
.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):

text
rm -f hello *.o

Without .PHONY and if a file called clean exists:

Output:

text
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.

VariableMeaning
$@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 $<
makefile
hello: hello.c utils.c
	gcc -o $@ $^

Output (make hello):

text
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.

makefile
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):

text
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:

makefile
# 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.

OperatorNameBehaviour
=RecursiveRight-hand side is re-expanded on every reference
:=Simple / immediateRight-hand side is expanded once, when the line is read
?=ConditionalAssign only if the variable is currently unset
+=AppendAppend to the existing value (recursive or simple, matching the original)
::=Simple (POSIX 2024)Same as :=, POSIX-standardised form
makefile
# 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
makefile
.PHONY: show
show:
	@echo "TODAY  = $(TODAY)"
	@echo "BUILT  = $(BUILT)"
	@echo "PREFIX = $(PREFIX)"
	@echo "CFLAGS = $(CFLAGS)"

Output (make show PREFIX=/opt/local):

text
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.

FunctionUse
$(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
makefile
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):

text
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.

makefile
$(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.

makefile
# 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.

makefile
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).

makefile
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):

text
cd /tmp
pwd
/home/alice/project

Output (make correct-cd):

text
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.

bash
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 -j but 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.

bash
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):

text
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.

bash
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.

FeatureWhat it does
--shuffle[=MODE]Reorder goals and prerequisites to fuzz-test parallel builds. MODE is random, reverse, or a fixed seed.
.NOTINTERMEDIATEMark targets so make never deletes them as "intermediate" after a chained pattern rule. Opposite of .INTERMEDIATE.
.WAITPseudo-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=fifoNew 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.
makefile
# 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):

text
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=random is the easiest way to find latent under-declared dependencies in a parallel build. Add a fuzz target 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.

ToolLanguageConfigSweet spot
justRustjustfileProject task runner — recipes take named arguments, just --list is auto-help, no TAB requirement, runs on Linux/macOS/Windows.
task (go-task)GoTaskfile.ymlYAML-based, native includes for monorepos, hash-based change detection (not timestamps), built-in watch mode.
mageGomagefile.goTargets are plain Go functions — full IDE support, strong typing, ideal when your project is already Go.
ninjaC++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 / buck2Java / RustBUILD filesHermetic, content-addressed, cache-shared builds at monorepo scale. Big upfront cost; pays off above ~500 engineers.
bash
# 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):

text
Available recipes:
    default
    deploy env="staging"
    lint
    test pattern=""
yaml
# 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):

text
task: [build] go build -o bin/myapp ./cmd/myapp

Pick just if you want the closest "Makefile but nicer" feel without dependency-graph features. Pick task if you want YAML, monorepo includes, and hash-based change detection. Pick ninja only when CMake / Meson is generating it for you. Keep make when you need ubiquity (every Unix has it), POSIX-portable timestamp-based incremental builds, or a single command newcomers already know.

Common pitfalls

  1. Tabs vs spaces — recipe lines must begin with a literal tab. Editors that auto-convert tabs silently break the build with *** missing separator. Stop.
  2. .PHONY omittedmake clean will appear to do nothing if a file named clean happens to exist. Always declare phony targets.
  3. Each line is a fresh shellcd dir; ./tool on two lines does not work. Use && and \ to keep one shell, or use a define block.
  4. Recursive = on $(shell …) — running date (or worse, a git log) on every reference can dominate build time. Default to :=.
  5. Globs at the wrong layerOBJS := *.o stores the literal string *.o, not the matching files. Use $(wildcard *.o) instead.
  6. Spaces in paths — make can't reliably handle paths containing spaces. Stick to portable filenames; use \ escape only when you must.
  7. Recursive make harms parallelismmake -C subdir launches 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."
  8. Silent failures with - prefix-cmd ignores the exit status. Never use it on commands whose failure should stop the build; reserve it for cleanup steps where errors don't matter.
  9. 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.

makefile
.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):

text
  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.

makefile
.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):

text
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.

makefile
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.

bash
make clean
/usr/bin/time -p make -j8 2>&1 | tail

Output:

text
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.

makefile
.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):

text
  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.

makefile
.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

make is the lowest-common-denominator task runner. Even on JavaScript or Python projects where the language has its own runner, a 10-line Makefile with install, lint, test, build, and clean makes the project trivially discoverable: make help always 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.mk to 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