Makefile Cheatsheet - Targets, Variables & Build Rules
A Makefile is a dependency graph, not a script. Targets whose prerequisites are unchanged are not rebuilt, and that single property is why a tool born for C still fronts every npm script and most static site tooling. What matters is learning the rule syntax: how a pattern rule such as %.o matches filenames, what $@, $< and $^ expand to, and how make decides which target to build first. Two mistakes recur constantly. Recipe lines must start with a real Tab, and a missing Tab produces a missing separator error rather than anything resembling a clue. Targets like clean that produce no file must be declared .PHONY, otherwise an unrelated file named clean makes make consider the job already done. The sheet groups variable assignment, automatic variables, rules, pattern rules, builtin functions, conditionals and worked examples. Keep prerequisite lines honest, because one wrong dependency produces a build that quietly skips work and reports success.
Typical Use Case
For engineers maintaining a project where anyone can clone it and get a build. A Makefile makes build order, dependencies and feature switches reviewable instead of a command line that lives in one person memory. Covered: the five assignment forms, automatic variables, pattern rules, built-in functions and conditionals. Three things break in practice: when a recursive = expands, a recipe typed with spaces instead of a tab, and pattern rules that omit header prerequisites.
Variables & Assignment 6
CC = gccCC := gccCC ?= gccCFLAGS += -Wallexport PATH := /opt/bin:$(PATH)$(CC) or ${CC}Automatic Variables 7
$@$<$^$+$*$?$(@D) / $(@F)Rules & Targets 5
target: deps
recipe.PHONY: clean installall: prog1 prog2clean:
rm -f *.o $(TARGET)install: target
cp target /usr/local/binPattern Rules 5
%.o: %.c
$(CC) -c $< -o $@%.o: %.c $(HDRS)$(OBJDIR)/%.o: $(SRCDIR)/%.cOBJS := $(SRCS:.c=.o)$(OBJS): obj/%.o: src/%.cBuilt-in Functions 8
$(wildcard *.c)$(patsubst %.c,%.o,$(SRCS))$(filter %.c %.h,$(SRCS))$(filter-out %.test,$(SRCS))$(strip $(string))$(shell date +%Y)$(subst from,to,text)$(foreach var,list,text)Conditionals 5
ifeq ($(OS),Windows_NT)
...
endififneq ($(CC),gcc)
...
endififdef DEBUG
CFLAGS += -g
endififndef RELEASE
...
else
...
endif$(if $(DEBUG),-g,-O2)Practical Examples 6
SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:.c=.o)include config.mk.DEFAULT_GOAL := installvpath %.c src:src2make -j4make CFLAGS="-O2 -g"Parameter matrix
| Parameter | Effect | Example |
|---|---|---|
CC = gcc | Recursive assignment; the variable expands when used, so later changes affect places already written. | CC = gcc |
CC := gcc | Simple assignment, evaluated immediately at definition; recommended for critical config to lock the value in. | CC := gcc |
CC ?= gcc | Assigns only if the variable is not already defined — the standard way to allow command-line or environment overrides. | CC ?= gcc |
CFLAGS += -Wall | Appends rather than overwrites; but a same-named variable passed on the command line overrides all Makefile additions by default. | CFLAGS += -Wall |
export PATH := /opt/bin:$(PATH) | Exports the variable to child processes (configure, test scripts) — the only way to pass the build environment. | export PATH := /opt/bin:$(PATH) |
$@ | The target name of the current rule; almost always used in compile and link commands. | $@ |
$< | The first prerequisite. In a pattern rule it is the input file, a fixed pairing with -o $@. | $< |
$^ | The ordered, de-duplicated list of all prerequisites; most common for link commands. | $^ |
$+ | All prerequisites, not de-duplicated and order preserved; for cases where the same object file appears multiple times. | $+ |
$* | The stem in a pattern rule (target minus suffix), or the filename minus directory for a suffixed target. | $* |
$? | The list of prerequisites newer than the target; used only for incremental compilation. | $? |
$(@D) / $(@F) | Directory of the target / filename of the target — avoids splitting paths by hand inside rules. | $(@D) / $(@F) |
.PHONY: clean install | Declares a phony target; not judged complete even when a folder of the same name exists in the directory. | .PHONY: clean install |
all: prog1 prog2 | Multi-target dependencies build in order; good for funnelling outputs through a single entry point. | all: prog1 prog2 |
%.o: %.c | Pattern rule: any .c produces a same-named .o; the recipe line must start with a Tab. | %.o: %.c |
%.o: %.c $(HDRS) | Lists headers as dependencies so changing a header triggers a rebuild — the key to correct incremental builds. | %.o: %.c $(HDRS) |
$(OBJS): obj/%.o: src/%.c | Static pattern rule; use it instead of a generic pattern rule when the source and target directories differ. | $(OBJS): obj/%.o: src/%.c |
OBJS := $(SRCS:.c=.o) | Suffix replacement; batch-maps a source file list into a target file list. | OBJS := $(SRCS:.c=.o) |
$(wildcard *.c) | Expands to the list of matched filenames, does not recurse into subdirectories, and returns an empty string when nothing matches. | $(wildcard *.c) |
$(patsubst %.c,%.o,$(SRCS)) | Pattern-based batch replacement, more flexible than suffix replacement. | $(patsubst %.c,%.o,$(SRCS)) |
$(filter %.c %.h,$(SRCS)) | Filters a list by pattern, keeping .c and .h files. | $(filter %.c %.h,$(SRCS)) |
$(filter-out %.test,$(SRCS)) | Inverse filter; removes matching entries from the list, often used to exclude test files. | $(filter-out %.test,$(SRCS)) |
$(shell date +%Y) | Runs a shell and captures stdout as a string; it truly executes once on every expansion. | $(shell date +%Y) |
$(subst from,to,text) | Literal replacement; does not support % wildcards. | $(subst from,to,text) |
$(foreach var,list,text) | Expands text once per list element; the workhorse for generating rules in bulk. | $(foreach var,list,text) |
$(strip $(string)) | Collapses extra whitespace and trims leading/trailing spaces; used to join conditional fragments. | $(strip $(string)) |
ifeq ($(OS),Windows_NT) | Equality test for cross-platform branches; nearly all MSVC vs MinGW path differences are smoothed over by it. | ifeq ($(OS),Windows_NT) |
ifdef DEBUG / ifndef RELEASE | Tests whether a variable is defined (definition only, not value), toggling flags like -g for debugging. | ifdef DEBUG |
$(if $(DEBUG),-g,-O2) | Inline ternary; picks compile options based on whether a variable is empty, more compact than a full ifeq block. | $(if $(DEBUG),-g,-O2) |
.DEFAULT_GOAL := install | Explicitly sets the default goal; otherwise make runs the first target that appears in a rule. | .DEFAULT_GOAL := install |
vpath %.c src:src2 | Restricts the search directories of a pattern rule, so same-named files in src/ and src2/ share one rule set. | vpath %.c src:src2 |
include config.mk | Includes another makefile to split out shared config; a leading - makes it optional when missing. | include config.mk |
make -j4 | Runs 4 jobs in parallel, sharply cutting compile time; rules writing the same artifact will race. | make -j4 |
make CFLAGS="-O2 -g" | A command-line assignment overrides the same-named variable inside the Makefile and disables its later +=. | make CFLAGS="-O2 -g" |
Common pitfalls
Symptommake reports "missing separator", or the recipe line is ignored entirely.
CauseThe recipe line does not start with a Tab. Most editors render Tab as spaces, and copy-paste turns it into spaces too.
FixReplace the line-leading whitespace with a real Tab, and configure the editor to show Tab as an arrow so it is easy to spot.
SymptomYou change a header file but it does not recompile, and linking fails with an undefined reference.
CauseThe pattern rule only says %.o: %.c and does not list the headers as dependencies, so make does not know .o depends on .h.
FixChange the rule to %.o: %.c $(HDRS); more robust still is compiling .c with -MMD -MP to generate .d files and including them.
Symptommake just reports "up to date", but the executable has not actually been built.
CauseThe target name collides with a directory of the same name, and make sees that directory and considers the target done.
FixAdd such targets to .PHONY, or rename the target so it does not clash with a directory.
SymptomYou change CC/CFLAGS on the command line but the additions inside the Makefile never show up in the final command.
CauseA command-line assignment outranks plain = and += inside the Makefile, so the latter are ignored wholesale.
FixWhen you need them to stack, use ?= or override CFLAGS += ..., and confirm the expanded command with make -n.
SymptomA variable expands to an empty string in some places and echoes nothing.
CauseThe recursive = assignment referenced a not-yet-defined variable; the order of assignment and reference decides the expansion.
FixUse := (immediate) uniformly for critical variables, and print them during parsing with $(info VAR=$(VAR)) to confirm.
SymptomSerial make works, but after make -j4 linking occasionally fails or artifacts are corrupted.
CauseUnder parallelism two targets race on the same intermediate, or a directory is generated and used with no dependency declared between them.
FixWrite explicit dependencies for all directory targets (e.g. obj/%.o: src/%.c | obj), and split any single recipe managing multiple targets into separate pattern rules.
Symptom$(wildcard *.c) matches no files at all.
Causewildcard expands relative to the directory of the Makefile and does not recurse into subdirectories; with all files under src/ it naturally matches nothing.
FixWrite subdirectory paths explicitly or use $(shell find src -name '*.c'), noting the latter depends on the shell environment.
SymptomA $(shell ...) line near the top of the Makefile reruns many times per make invocation, and its output breaks across lines and corrupts the syntax.
Cause$(shell) re-executes on every variable expansion, and referencing it in a recursive assignment multiplies the count.
FixSwitch to := (immediate) so it runs only once, and handle multi-line output with $(strip ...).
Troubleshooting
1Print only, do not execute — inspect the real expanded command.
make -n targetThis step best exposes Tab, empty-variable, and path-joining problems.
2Print the rule database to confirm targets and dependencies.
make -p -n-p lists built-in rules and variables; -n only prints without executing.
3Build in parallel to observe whether dependencies are declared fully.
make -j4If serial passes but parallel fails, undeclared dependencies or shared intermediate files are almost certain.
4Force a target to rebuild unconditionally.
make -B target-B removes the interference of "the timestamp did not change so it will not build".
5Confirm which make implementation and version is in use.
make --versionGNU make and BSD make behave differently; scripts should first confirm the target environment.
Command Examples
A standard C build with a pattern rule
CC := gcc
CFLAGS := -Wall -Wextra -O2
OBJS := $(SRCS:.c=.o)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
app: $(OBJS)
$(CC) $(CFLAGS) -o $@ $^$< is the first prerequisite (source file), $@ is the target name (.o), $^ is all prerequisites (link stage). Use := rather than = to expand immediately, so values are not evaluated only at reference time.
Calling a shell command from a Makefile
clean:
rm -f *.o
@echo "Cleanup done"
list:
@ls -1 *.o | wc -lA recipe only counts when the line starts with a TAB; spaces are treated as plain text. The @ prefix suppresses command echo; without it, rm -f *.o is echoed too, which is noisy in CI logs.
Output
Cleanup done
Find out why a target is being rebuilt
make -n app
make -d app 2>&1 | grep -i "newer than"-n only prints without executing, to preview what would happen; -d prints the full decision process, showing which dependency make judged newer. To debug "it always recompiles even though nothing changed", check this first.
Output
gcc -Wall -O2 -c main.c -o main.o gcc -Wall -O2 -c util.c -o util.o
Common Pitfalls
- Recipe lines must begin with a TAB, not spaces — the most common Makefile mistake, usually reported as "missing separator".
- A target sharing a name with a real file is considered up to date; declare .PHONY to avoid it, especially for clean and install.
- With =, variables expand at the point of use, which frequently causes "I changed it and nothing happened" — prefer := for paths and includes.
- A hard-coded source list is not re-scanned, so make never notices a new file; collect with $(wildcard *.c) at parse time.
- Under parallel make -j, undeclared ordering dependencies between targets can produce incorrect results.
Tips
- Recipe lines MUST start with a Tab, not spaces - the most common mistake.
- The .PHONY target ignores a same-named file on disk; good for clean/install.
- $@ = target, $< = first prerequisite, $^ = all prerequisites - the three most-used automatic vars.
- := expands immediately, = defers; prefer := to avoid recursive side effects.
- include other.mk pulls in another Makefile, good for large projects.
- CLI variables override Makefile assignments; use override to prevent that.
FAQ
What is the difference between := and = for Makefile variables?
= expands recursively — any variables referenced inside are resolved at the point of use. := expands immediately, at the moment of definition. With CC = gcc, reassigning CC later still affects earlier references, whereas CC := gcc freezes the value on definition. Use := for anything involving paths or other variables, otherwise you get the classic confusion where a change appears to do nothing.
What is .PHONY and why does clean need it?
Make decides whether to run a recipe by checking whether the target file exists and is newer than its dependencies. If a file literally named clean happens to exist, make clean concludes the target is up to date and does nothing. Declaring .PHONY: clean tells make the target produces no file and must always run. Strictly, any target that does not create a file should be declared phony.
What do $@, $<, $^ and $* expand to?
$@ is the current target name, $< is the first prerequisite (used by compilers), $^ is all prerequisites deduplicated, $+ is the same but keeps duplicates, which matters when you deliberately link an object file twice. $* is what % matched in a pattern rule. The classic %.o: %.c rule with $(CC) -c $< -o $@ is built entirely from these.
Why does make skip my newly added source file?
Make resolves prerequisites by name, so a hard-coded list of src/*.o will never pick up a new file automatically. Replace the fixed list with $(wildcard src/*.c) to collect files at parse time, then derive targets with $(SRCS:.c=.o), or declare targets explicitly with a static pattern rule. Also keep variable definitions ahead of the rules that consume them.
How do I choose the parallelism for make -j?
Match it to the core count — make -j4 saturates a four-core machine. Parallelism requires accurate dependency declarations: if targets have an implicit order you did not express, parallel builds will produce wrong results. Do not oversubscribe either, since linking is memory hungry and far more jobs than cores can fail from exhaustion.
Official References
Each command links to its official documentation below, so you can verify the latest usage and read deeper.
Maintained by LaoHand
Publicly updated on Jul 19, 2026, continuously proofread against official docs.
Contact Us
Wrong command or description? Send us corrections, business inquiries or product feedback by email.
Contact Us