make is a build automation tool that determines which parts of a program need recompilation and executes the right commands. It reads a Makefile that describes the build graph as targets and their dependencies.
Quick reference
| Task | Syntax |
|---|---|
| Run default target | make |
| Run specific target | make build |
| Dry run (print, don't run) | make -n build |
| Parallel jobs | make -j4 |
| Specify Makefile | make -f other.mk |
| Pass variable | make ENV=prod |
| Keep going on error | make -k |
| Silent mode | make -s |
| Print database | make -p |
| Remake without checking dates | make -B |
| Target with prerequisites | target: dep1 dep2 |
| Phony target | .PHONY: clean test |
| Automatic variable — output | $@ |
| Automatic variable — first dep | $< |
| Automatic variable — all deps | $^ |
Rule anatomy
target: prerequisites
recipe-line-1
recipe-line-2
- target — the file or label to build
- prerequisites — files that must be up-to-date before this target runs
- recipe — shell commands, each line must be indented with a TAB, not spaces
# Minimal example — compile main.o from main.c
main.o: main.c main.h
gcc -c main.c -o main.o
Variables
Defining variables
# Simply expanded (evaluated once at assignment)
CC := gcc
CFLAGS := -Wall -O2
# Recursively expanded (evaluated on every use)
SRCS = $(wildcard src/*.c)
# Conditional assignment (set only if not already defined)
BUILD_DIR ?= build
# Append to existing value
CFLAGS += -g
Using variables
# $(VAR) or ${VAR}
CC := gcc
compile:
$(CC) $(CFLAGS) -o app main.c
Override from command line
# In Makefile: CFLAGS := -O2
# Command line: make CFLAGS="-O0 -g" — overrides MAKEFILE value
Environment variables
# Environment variables are available automatically
deploy:
echo "Deploying to $(DEPLOY_HOST)"
# Export Make variable to child processes
export PATH := $(PATH):/custom/bin
Automatic variables
| Variable | Meaning |
|---|---|
$@ |
Name of the target |
$< |
Name of the first prerequisite |
$^ |
All prerequisites (deduplicated) |
$+ |
All prerequisites (with duplicates) |
$? |
Prerequisites newer than the target |
$* |
Stem matched by % in pattern rule |
$(@D) |
Directory part of target |
$(@F) |
File part of target |
$(<D) |
Directory part of first prerequisite |
$(<F) |
File part of first prerequisite |
build/%.o: src/%.c
mkdir -p $(@D)
$(CC) -c $< -o $@
# $@ = build/foo.o, $< = src/foo.c, $* = foo
Pattern rules
# Compile any .c file to .o in the same directory
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Compile .c from src/ to .o in build/
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
# Convert any .md to .html
%.html: %.md
pandoc $< -o $@
Static pattern rules (apply to specific targets)
OBJS := foo.o bar.o baz.o
$(OBJS): %.o: %.c
$(CC) -c $< -o $@
Phony targets
Phony targets are labels, not real files. Always declare them with .PHONY:
.PHONY: all build test clean lint install
all: build
build:
go build -o bin/app ./cmd/app
test:
go test ./...
clean:
rm -rf bin/ build/
lint:
golangci-lint run ./...
install: build
cp bin/app /usr/local/bin/
Without .PHONY, if a file named clean exists, make clean will say "nothing to be done".
Order-only prerequisites
Use | to declare a prerequisite that must exist but whose timestamp does not trigger a rebuild:
build/%.o: src/%.c | build
$(CC) -c $< -o $@
build:
mkdir -p build
Without | build, touching the directory would cause every object file to rebuild.
Built-in functions
Text functions
# $(subst from,to,text)
NEW := $(subst .c,.o,main.c) # main.o
# $(patsubst pattern,replacement,text)
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
# $(strip text) — remove leading/trailing whitespace
STRIPPED := $(strip hello world )
# $(filter pattern, text) — keep matching words
C_FILES := $(filter %.c, $(FILES))
# $(filter-out pattern, text) — remove matching words
NON_C := $(filter-out %.c, $(FILES))
# $(sort list) — sort and deduplicate
SORTED := $(sort foo bar baz foo) # bar baz foo
# $(word n,text) — nth word (1-based)
FIRST := $(word 1, a b c) # a
# $(words text) — count words
COUNT := $(words a b c) # 3
# $(firstword text)
F := $(firstword a b c) # a
# $(lastword text)
L := $(lastword a b c) # c
# $(dir names) — directory part
D := $(dir src/foo.c) # src/
# $(notdir names) — file part
N := $(notdir src/foo.c) # foo.c
# $(suffix names)
S := $(suffix src/foo.c) # .c
# $(basename names)
B := $(basename src/foo.c) # src/foo
# $(addsuffix suffix,names)
HTMLS := $(addsuffix .html,index about) # index.html about.html
# $(addprefix prefix,names)
PATHS := $(addprefix build/,foo bar) # build/foo build/bar
# $(join list1,list2)
J := $(join a b c, .x .y .z) # a.x b.y c.z
File functions
# $(wildcard pattern)
SRCS := $(wildcard src/**/*.c)
# $(abspath path) — resolve to absolute path (without shell)
ABS := $(abspath ../lib)
# $(realpath path) — resolve symlinks too
REAL := $(realpath ../lib)
Conditional functions
# $(if condition,then[,else])
MSG := $(if $(DEBUG),debug mode,release mode)
# $(or cond1,cond2,...) — first non-empty
VAL := $(or $(A),$(B),default)
# $(and cond1,cond2,...) — last non-empty if all non-empty
BOTH := $(and $(A),$(B))
Foreach and call
# $(foreach var,list,text)
FILES := a b c
PATHS := $(foreach f,$(FILES),src/$(f).c)
# → src/a.c src/b.c src/c.c
# $(call func,arg1,arg2) — call user-defined function
define greet
Hello, $(1)! You are $(2) years old.
endef
MSG := $(call greet,Alice,30)
Shell function
# $(shell command) — run shell, capture output
GIT_HASH := $(shell git rev-parse --short HEAD)
DATE := $(shell date +%Y-%m-%d)
Conditionals
# ifeq / ifneq
ifeq ($(ENV),prod)
CFLAGS += -O3
else
CFLAGS += -O0 -g
endif
# ifdef / ifndef
ifndef CC
CC := gcc
endif
ifdef VERBOSE
Q :=
else
Q := @
endif
build:
$(Q)$(CC) -o app main.c
Multi-line variables and define
# define ... endef creates a multi-line variable
define HELP_TEXT
Usage:
make build — compile the project
make test — run tests
make clean — remove build artifacts
endef
help:
@echo "$(HELP_TEXT)"
# define a function (callable with call)
define compile_rule
$(1): $(2)
$(CC) -c $(2) -o $(1)
endef
# Generate rules dynamically
$(eval $(call compile_rule,foo.o,foo.c))
$(eval $(call compile_rule,bar.o,bar.c))
Recipe tricks
# Silence a line with @
build:
@echo "Building..." # printed, not the command itself
$(CC) -o app main.c # command is echoed
# Continue on error with -
clean:
-rm -rf build/ # '-' ignores errors
# Multi-line recipe with line continuation
build:
$(CC) \
-Wall \
-O2 \
-o app \
main.c
# Run in the same sub-shell (avoid separate subshells per line)
deploy:
cd /var/app && \
git pull && \
systemctl restart app
# Export variable to all recipes in this Makefile
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c
Include and modular Makefiles
# Include another Makefile
include config.mk
-include .env.mk # '-' means don't error if missing
# Auto-include generated dependency files
-include $(OBJS:.o=.d)
# Generate dependency files with gcc
build/%.o: src/%.c | build
$(CC) -MMD -MP -c $< -o $@
Real-world patterns
C/C++ project
CC := gcc
CFLAGS := -Wall -Wextra -O2
SRCDIR := src
BUILDDIR:= build
TARGET := bin/app
SRCS := $(wildcard $(SRCDIR)/*.c)
OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS) | bin
$(CC) $(CFLAGS) $^ -o $@
$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
$(BUILDDIR) bin:
mkdir -p $@
clean:
rm -rf $(BUILDDIR) bin/
-include $(DEPS)
Go project
APP := myapp
CMD := ./cmd/$(APP)
BINARY := bin/$(APP)
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
.PHONY: all build test lint clean run
all: lint test build
build:
@mkdir -p bin
go build $(LDFLAGS) -o $(BINARY) $(CMD)
test:
go test -race -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
lint:
golangci-lint run ./...
run: build
./$(BINARY)
clean:
rm -rf bin/ coverage.out
Node.js project
NODE_ENV ?= development
.PHONY: install dev build test lint clean
install:
npm ci
dev:
NODE_ENV=$(NODE_ENV) npm run dev
build:
NODE_ENV=production npm run build
test:
npm test -- --passWithNoTests
lint:
npm run lint
clean:
rm -rf node_modules dist .next coverage
Docker workflow
IMAGE := myapp
TAG := $(shell git rev-parse --short HEAD)
REGISTRY:= registry.example.com
.PHONY: build push run
build:
docker build -t $(IMAGE):$(TAG) -t $(IMAGE):latest .
push: build
docker tag $(IMAGE):$(TAG) $(REGISTRY)/$(IMAGE):$(TAG)
docker push $(REGISTRY)/$(IMAGE):$(TAG)
docker push $(REGISTRY)/$(IMAGE):latest
run:
docker run --rm -p 8080:8080 $(IMAGE):latest
Help target (self-documenting)
.PHONY: help
## help: Show this help message
help:
@grep -E '^## ' $(MAKEFILE_LIST) | \
sed 's/## //' | \
awk -F: '{printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
## build: Compile the project
build: ...
## test: Run all tests
test: ...
## clean: Remove build artifacts
clean: ...
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
| Spaces instead of TAB | *** missing separator. Stop. |
Use actual TAB character for recipes |
Missing .PHONY |
make clean skipped if clean file exists |
Add .PHONY: clean |
$(shell ...) in recipe |
Runs at parse time, not recipe time | Use $$() or just use the shell command directly |
| Variable with spaces | CC = gcc -Wall splits on space for some functions |
Use := and quote carefully |
Forgetting - before error commands |
Build aborts on rm if file missing |
- rm -rf build/ |
Recursive make without $(MAKE) |
Doesn't pass -j flags and won't recurse properly |
Always use $(MAKE) not make |
| Circular dependencies | Silent infinite loop or cryptic error | Draw the dep graph, break cycles |
| No order-only prereq for directories | Directory timestamp causes spurious rebuilds | target: deps | dir |
make vs other build tools
| Feature | Make | CMake | Bazel | Ninja | Gradle |
|---|---|---|---|---|---|
| Language | Makefile DSL | CMakeLists.txt | Starlark | .ninja | Groovy/Kotlin |
| Dependency tracking | File timestamps | Generated | Cryptographic | File timestamps | Task graph |
| Incremental builds | Yes | Yes | Yes (hermetic) | Yes | Yes |
| Parallel builds | -j flag |
Yes | Yes | Default | Yes |
| Cross-platform | Unix/WSL | Yes | Yes | Yes | Yes |
| Learning curve | Medium | High | Very high | Low | High |
| Primary use case | C/C++, general | C/C++, CMake projects | Large monorepos | Ninja backend | JVM, Android |
Frequently asked questions
Q: Why do I get *** missing separator. Stop.?
Your recipe line starts with spaces instead of a TAB. Every recipe line must begin with a literal TAB character. Most editors can be configured to insert tabs in Makefiles.
Q: How do I run make in parallel?
Use make -j (all cores) or make -j4 (4 cores). For correctness, declare all dependencies properly so parallel jobs don't race. Add .NOTPARALLEL: at the top to disable parallelism for a Makefile.
Q: How do I pass variables to make from the command line?
make build ENV=prod DEBUG=1
# Inside Makefile: $(ENV) = prod, $(DEBUG) = 1
Command-line variables override Makefile assignments unless you use override in the Makefile.
Q: What is the difference between := and =?
= (recursive) is re-evaluated every time it's referenced — it can reference variables defined later in the file but can cause infinite loops. := (simply expanded) is evaluated once at the point of definition — safer, faster, and easier to reason about.
Q: How do I print variable values for debugging?
# Info target
print-%:
@echo $* = $($*)
# Use: make print-CC → CC = gcc
Or run make -p | grep "^VAR =" to dump the entire Make database.
Q: How do I avoid rebuilding when a header changes?
Use -MMD -MP with GCC/Clang to generate .d dependency files automatically, then include them:
build/%.o: src/%.c | build
$(CC) -MMD -MP -c $< -o $@
-include $(OBJS:.o=.d)
This causes make to rebuild .o files when any included header changes.