Make#
make is the original build DSL.
A Makefile lists targets with their prerequisites and recipes;
make figures out what to rebuild based on file timestamps. Created
in 1976; standardized by POSIX; ubiquitous.
Make is brittle when used as a build system for large polyglot projects. It’s still the best lightweight task runner in existence, which is how most modern projects use it.
The Building Block#
target: prerequisite1 prerequisite2
<TAB>recipe-line-1
<TAB>recipe-line-2
target, the file produced (or a “phony” task name).
prerequisites, files / targets that must exist (or be up-to-date) first.
recipe, the shell commands to build the target.
Each recipe line must start with a TAB, not spaces. The single most common Make beginner trap.
A Minimal Build#
CC ?= cc
CFLAGS ?= -O2 -Wall -Wextra
LDFLAGS ?=
OBJS := main.o util.o
app: $(OBJS)
<TAB>$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c
<TAB>$(CC) $(CFLAGS) -c $< -o $@
clean:
<TAB>rm -f app $(OBJS)
.PHONY: clean
$@, the target.$<, the first prerequisite.$^, all prerequisites.%, pattern;%.o: %.csays “any .o depends on the matching .c”.
Phony Targets (Make as Task Runner)#
When the “target” isn’t actually a file, mark it .PHONY:
.PHONY: build test fmt lint clean run
build:
<TAB>go build ./...
test:
<TAB>go test ./...
fmt:
<TAB>gofmt -w .
lint:
<TAB>golangci-lint run
run: build
<TAB>./app
clean:
<TAB>rm -rf bin/
This pattern (“Make as a project’s CLI”) is the dominant 2026 use.
Variables#
Make has several assignment operators with different
expansion semantics. = expands lazily on every use;
:= expands once at definition time; ?= defaults
without overriding; += appends. Picking the right form
prevents the most common Makefile bugs.
Form |
Behavior |
|---|---|
|
recursively expanded; expands every reference |
|
simply expanded; expands once when defined |
|
set only if not already |
|
append |
|
win over command-line / env definitions |
# Simple expansion (preferred when expansion order matters)
GO := $(shell command -v go)
# Conditional default
PORT ?= 8080
# Append to a list
FLAGS := -O2
FLAGS += -Wall
Automatic Variables#
Inside a recipe, Make exposes a small set of automatic variables for the target, prerequisites, and pattern stem. Using them correctly is what turns a Makefile from “list every file by hand” into a small declarative description that generalizes across hundreds of files.
Variable |
Meaning |
|---|---|
|
target name |
|
first prerequisite |
|
all prerequisites (deduplicated) |
|
all prerequisites (with duplicates) |
|
the stem of a pattern match |
|
directory part of $@ |
|
file part of $@ |
Functions#
GNU Make is a real (if uncomfortable) programming language.
SOURCES := $(wildcard src/*.c)
OBJECTS := $(patsubst src/%.c, build/%.o, $(SOURCES))
ifeq ($(OS),Windows_NT)
EXE := .exe
else
EXE :=
endif
define banner
============== $(1) ==============
endef
release:
<TAB>@$(call banner,Building release)
<TAB>$(MAKE) build CFLAGS="-O3"
The DSL has foreach, call, shell, filter, patsubst,
addprefix, addsuffix, conditionals, and includes. The syntax is
ugly; the power is real.
Common Patterns#
Help target, self-documenting Makefile.
.PHONY: help
help:
<TAB>@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
<TAB><TAB>awk 'BEGIN { FS = ":.*?## " } { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }'
build: ## Compile the binary
<TAB>go build -o bin/app ./cmd/app
test: ## Run all tests
<TAB>go test ./...
Tool versions / dependencies:
GO_VERSION := $(shell go version | cut -d ' ' -f 3)
Watch / re-run:
.PHONY: watch
watch:
<TAB>find . -name '*.go' | entr -r $(MAKE) test
Variants#
The Make implementations an operator may meet across systems. GNU Make is the de-facto standard with the richest features; BSD Make has different conditional syntax; NMake is Windows-only; bmake is the portable BSD variant. Targeting GNU Make is safe for most new Makefiles.
GNU make, the de-facto standard; rich features,
--jobs, pattern rules,include.BSD make, on FreeBSD / NetBSD / OpenBSD; different syntax for conditionals.
NMake, Microsoft’s; Windows-only.
bmake, portable BSD make.
For new Makefile``s, target GNU make and call it ``Makefile;
target BSD-portable Make explicitly when needed.
Modern Alternatives#
The 2026 landscape of task runners and build systems. Just is the friendliest replacement for “Make as task runner”; Task is the YAML alternative; Bazel and Buck2 are hermetic build systems for monorepos; CMake and Ninja drive C/C++ builds; language-native runners cover their own ecosystems.
Just, task runner; not a build system; no tabs, sane variables. Increasingly the default for “Make as task runner”.
Task, YAML task runner.
Bazel, Buck2 – hermetic build systems for huge polyglot codebases.
CMake, the C / C++ portable build generator (still emits Makefiles or Ninja).
Ninja, low-level, very fast builder; generated by CMake / Meson / Bazel rather than written by hand.
Mage, Make-replacement in Go.
npm scripts, cargo, mix, mvn, language-native task runners.
For new task-runner needs, Just is often the right choice. For build systems beyond a single language, Bazel or CMake.
When to Use Make#
The kinds of project where Make is still the right tool.
Lightweight project CLIs, moderate-size C/C++ codebases, and
the universal “every project has a make build” convention
all play to Make’s strengths. Polyglot monorepos and hermetic
builds want a real build system instead.
As a project’s lightweight CLI:
make test,make fmt,make build.For C / C++ codebases of moderate size.
Anywhere you want a “every project has a
make build” convention.
When not to.
Polyglot monorepos, look at Bazel / Buck2 / Nx / Turbo.
Anything that needs hermeticity or remote caching.
When the scripts are getting long; switch to Just or shell scripts.
Pitfalls#
The traps that catch every Make user. Tabs versus spaces is
the famous one; recursive versus simple expansion is the
silent one; per-line subshells produce surprising state
loss; -j parallelism reveals missing prerequisites; and
shell portability across platforms takes care.
Tabs, not spaces, the rule that breaks every newcomer.
``=`` vs. ``:=``, recursive vs. simple expansion; misuse surprises everywhere.
Implicit rules,
%.o: %.cis built in but easy to override by accident.Subshell per recipe line, each line runs in its own shell unless you use
\continuations or.ONESHELL:.Parallelism,
make -jexposes missing dependencies between targets. Add real prerequisites; don’t paper over with serial runs.Cross-platform, shell commands on Linux differ from those on macOS / Windows. Pin a portable shell or warn users.