Make
make + Makefiles—what they are, how they work, the gotchas, and how to write Makefiles that don’t bite you later.
What make actually is
make is a build orchestration tool:
- You declare targets (things you want), and prerequisites (things they depend on).
makedecides what is out of date by comparing file timestamps.- It runs recipes (shell commands) to bring targets up to date.
It’s fundamentally a dependency graph executor—not just for compiling C/C++, but also for “run tests”, “build docker”, “deploy”, etc.
Core mental model
Rule format
git rev-parse –short HEAD
target: prereq1 prereq2
<TAB> command 1
<TAB> command 2
Important: recipes must start with a TAB (unless you change .RECIPEPREFIX).
Timestamp logic
If target is missing, or older than any prerequisite, the recipe runs.
So this:
app: main.o util.o
$(CC) -o $@ $^
means: if app is older than main.o or util.o, rebuild.
Most important special variables
| Variable | Meaning |
|---|---|
$@ |
target name |
$< |
first prerequisite |
$^ |
all prerequisites (deduped) |
$+ |
all prerequisites (keeps duplicates) |
$* |
“stem” in pattern rules (e.g. %.o: %.c) |
Example:
%.o: %.c
$(CC) -c $< -o $@
Phony targets (the #1 gotcha)
If you have a target like test: and there’s a file named test in the directory, make test can stop working properly.
Fix with:
.PHONY: test lint format
Use .PHONY for commands that don’t produce a file with that name.
Each recipe line runs in a new shell (common pitfall)
This doesn’t work:
bad:
cd backend
python manage.py migrate
Because cd backend runs in one shell, the next line in another.
Fix by:
Option A: chain with &&
good:
cd backend && python manage.py migrate
Option B: use .ONESHELL
.ONESHELL:
good:
cd backend
python manage.py migrate
If you use .ONESHELL, it applies to all recipes (unless scoped with target-specific trickery).
Default shell and strictness
By default, make uses /bin/sh (often dash on Ubuntu). If you rely on bashisms, set:
SHELL := /usr/bin/env bash
If you want “fail fast” behavior:
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
-e: stop on errors-u: undefined vars are errorspipefail: pipelines fail if any command fails
Variables: =, :=, ?=, +=
These matter a lot.
| Operator | Meaning |
|---|---|
= |
recursive (lazy) expansion |
:= |
simple (immediate) expansion |
?= |
set only if not already set |
+= |
append |
Example:
A = $(B)
B = hi
# A expands to "hi" (because = is lazy)
C := $(B)
# C expands to current B right now
Tip: Prefer := unless you need lazy behavior.
Environment variables & overrides
- Environment variables are visible to
make. - You can override from CLI:
make ENV=prodIn Makefile:
ENV ?= dev
deploy:
./deploy.sh $(ENV)
If you want a variable to be exported into recipe shells:
export DJANGO_SETTINGS_MODULE := config.settings
Includes & multi-file Makefiles
Split Makefiles cleanly:
include make/django.mk
include make/docker.mk
If the file might not exist:
-include .env.mk
Pattern rules, automatic builds (why make is powerful)
Pattern rule:
%.min.js: %.js
minify $< > $@
Static pattern rule:
objects := a.o b.o
$(objects): %.o: %.c
$(CC) -c $< -o $@
Functions you’ll use constantly
$(wildcard ...)
PY_FILES := $(wildcard src/**/*.py)
$(shell ...)
GIT_SHA := $(shell git rev-parse --short HEAD)
$(patsubst pattern,repl,text)
SRC := a.c b.c
OBJ := $(patsubst %.c,%.o,$(SRC))
$(foreach var,list,text)
print:
@$(foreach f,$(SRC),echo $(f);)
Conditionals
ifeq ($(ENV),prod)
FLAGS += --optimize
else
FLAGS += --debug
endif
Target-specific variables (super useful)
test: PYTEST_FLAGS := -q
test:
pytest $(PYTEST_FLAGS)
Only applies to that target.
Parallel builds
Run in parallel:
make -j
make -j8But be careful: if recipes write to shared files/dirs without proper deps, parallel runs will race.
Order-only prerequisites (directory creation without timestamp sensitivity)
Example: ensure build/ exists, but don’t rebuild just because build/ timestamp changes:
build/app: src/main.c | build
$(CC) $< -o $@
build:
mkdir -p build
The | means order-only prerequisite.
Good “help” target pattern (what you were using)
The pattern you posted is common: parse ## comments to auto-generate help.
A minimal form:
.PHONY: help
help:
@awk 'BEGIN {FS=":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
Use $(MAKEFILE_LIST) instead of $(MAKEFILE) so it works across included Makefiles.
Anti-patterns (what breaks Makefiles)
- Not using
.PHONYfor command targets - Assuming bash when
shis used - Multi-line shell state without
.ONESHELL - Tabs vs spaces issues
- Making everything always run (turning make into a task runner that ignores deps—sometimes fine, but then
makegives you less value)
Make as a “task runner” vs real dependency builds
For Django/dev workflows, many teams use make as a task runner:
make migratemake testmake run
That’s okay—just be explicit that these targets are .PHONY.
If you actually want make’s power, use it for:
- generated files (compiled assets)
- build artifacts
- codegen outputs
- docs builds
- container images (with tags as targets)
Debugging make
See what it would do (no execution)
make -n targetVerbose / debug rule resolution
make -d targetPrint variable value
make -p | lessOr add:
print-%:
@echo $*=$($*)
Then:
make print-SHELL
make print-ENVA clean Makefile template for a Django project
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show commands
@awk 'BEGIN {FS=":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: venv
venv: ## Create venv
python -m venv .venv
.PHONY: install
install: ## Install deps
. .venv/bin/activate && pip install -r requirements.txt
.PHONY: run
run: ## Run dev server
. .venv/bin/activate && python manage.py runserver
.PHONY: migrate
migrate: ## Apply migrations
. .venv/bin/activate && python manage.py migrate
.PHONY: test
test: ## Run tests
. .venv/bin/activate && pytest -q
When you should not use Make
If you:
- need cross-platform Windows support without WSL,
- want rich argument parsing / subcommands,
- want better UX + autocompletion,
then tools like just, task, invoke, nox, poetry scripts, npm scripts, or uv run wrappers can be cleaner.
But make stays great when you want:
- simple command hub
- dependency-based builds
- ubiquitous tooling with zero extra install on Linux/macOS
Cheat sheet (quick reference)
| Need | Use | |
|---|---|---|
| default target | .DEFAULT_GOAL := help |
|
| phony targets | .PHONY: ... |
|
| bash + strict mode | SHELL := ... + .SHELLFLAGS := ... |
|
| same shell for all recipe lines | .ONESHELL: |
|
show help from ## |
awk over $(MAKEFILE_LIST) |
|
| directories as deps | order-only prereqs | dir |
|
| override vars in CLI | make ENV=prod |
|
| parallel runs | make -j |
If you want, paste your current Makefile (or the top-level layout with includes), and I’ll:
- normalize it (bash strict mode +
MAKEFILE_LISThelp) - remove sharp edges (
cdissues, missing.PHONY) - and show a “dependency-aware” version where it actually benefits (assets/build artifacts).