make reads a file called Makefile, figures out which targets are out of date, and runs only the commands needed to bring them up to date. It shipped in 1976 and it's still the most portable task runner you'll find on any Unix-like system — no install, no config, just make.
In short: a Makefile defines targets, their dependencies, and the shell commands (recipes) to build them. make checks file timestamps to skip work that's already done. Beyond compilation, it works as a language-agnostic task runner for Docker builds, test suites, deployments, and anything you'd otherwise put in a shell script.
Why Make Still Matters
Every language ecosystem has its own build tool — npm scripts, pip/tox, go build, cargo. So why bother with make?
It's already installed. Every Linux distro and macOS ships with make. No npm install, no pip install, no runtime dependency. Your CI server has it too.
It's language-agnostic. One Makefile can orchestrate Go compilation, Python linting, Docker builds, and Terraform deploys. When your project spans multiple languages (and most real projects do), make gives you a single entry point: make build, make test, make deploy.
It skips redundant work. make compares file timestamps. If nothing changed, it does nothing. This isn't just for C compilation — any workflow with input files and output files benefits.
I started using make as a task runner for 32blog — a Next.js project with linting scripts, OGP generation, and build steps. Instead of remembering node scripts/lint-articles.mjs && node scripts/generate-ogp-data.mjs && npm run build, I just type make build. When a teammate (or future me) clones the repo, make help shows every available command.
Makefile Anatomy: Targets, Dependencies, Recipes
A Makefile is built from rules. Each rule has three parts:
target: dependencies
recipe
- Target: the file you want to create (or a command name)
- Dependencies (prerequisites): files that must exist and be up-to-date before the recipe runs
- Recipe: shell commands to create the target (must be indented with a tab, not spaces)
Here's a concrete example:
build/app: src/main.c src/utils.c src/utils.h
gcc -o build/app src/main.c src/utils.c
When you run make build/app, make checks:
- Does
build/appexist? - Is it newer than all three source files?
- If yes → do nothing. If no → run gcc.
Multiple targets and the default
make runs the first target by convention. This is typically all:
all: build test
build: build/app
build/app: src/main.c
gcc -o build/app src/main.c
test:
./run-tests.sh
clean:
rm -rf build/
Running make (no arguments) executes all, which triggers build then test. Running make clean only executes the clean target.
Cheat sheet: rule structure
target: prerequisite1 prerequisite2
command1
command2
Each line in the recipe is executed in a separate shell. This means cd in one line doesn't affect the next:
# BROKEN: cd only applies to first command
wrong:
cd src
gcc -o app main.c
# CORRECT: chain with && or use a single line
right:
cd src && gcc -o app main.c
Variables and Automatic Variables
Variables reduce repetition and make your Makefile maintainable.
User-defined variables
CC = gcc
CFLAGS = -Wall -O2
SRC = src/main.c src/utils.c
TARGET = build/app
$(TARGET): $(SRC)
$(CC) $(CFLAGS) -o $@ $^
=is recursively expanded (re-evaluated every time it's used):=is simply expanded (evaluated once at assignment time)?=sets a default that can be overridden:make CC=clang
# := vs = matters when variables reference other variables
A = $(B)
B = hello
# $(A) → "hello" (recursive, evaluates B at use time)
C := $(B)
B = world
# $(C) → "hello" (simple, evaluated B at assignment time)
Automatic variables
These are set by make for each rule:
| Variable | Meaning | Example |
|---|---|---|
$@ | Target name | build/app |
$< | First prerequisite | src/main.c |
$^ | All prerequisites (deduped) | src/main.c src/utils.c |
$? | Prerequisites newer than target | changed files only |
$* | The stem matched by % | see pattern rules |
build/app: src/main.c src/utils.c
$(CC) $(CFLAGS) -o $@ $^
# Expands to: gcc -Wall -O2 -o build/app src/main.c src/utils.c
$@ and $^ are the ones you'll use 90% of the time. They eliminate hardcoded paths in recipes, which means you can rename files and only update the target/dependency line.
Pattern Rules and Wildcards
When you have many files following the same build pattern, writing individual rules is tedious. Pattern rules solve this.
The % wildcard
# Convert any .c file to a .o file
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
% matches any string. So %.o: %.c means "to create foo.o, look for foo.c." The matched part is available as $*:
%.o: %.c
@echo "Compiling $* → $@"
$(CC) $(CFLAGS) -c $< -o $@
# For foo.o: "Compiling foo → foo.o"
Combining with wildcard and patsubst
SRC = $(wildcard src/*.c)
OBJ = $(patsubst src/%.c, build/%.o, $(SRC))
build/app: $(OBJ)
$(CC) -o $@ $^
build/%.o: src/%.c
@mkdir -p build
$(CC) $(CFLAGS) -c $< -o $@
$(wildcard src/*.c) expands to all .c files in src/. $(patsubst src/%.c, build/%.o, $(SRC)) transforms src/main.c into build/main.o. Now adding a new source file requires zero Makefile changes — make discovers it automatically.
Useful built-in functions
FILES = one.c two.c three.c
# String substitution
OBJS = $(FILES:.c=.o) # one.o two.o three.o
# Filter
HEADERS = $(filter %.h, $(ALL_FILES))
# Shell command
GIT_HASH = $(shell git rev-parse --short HEAD)
# Conditional
DEBUG ?= 0
ifeq ($(DEBUG), 1)
CFLAGS += -g -DDEBUG
else
CFLAGS += -O2
endif
.PHONY and Common Pitfalls
The .PHONY problem
What happens if you have a target called clean and a file called clean exists in your directory?
$ touch clean
$ make clean
make: 'clean' is up to date.
make thinks the file clean is the target and it's already built. To tell make "this target is not a file," declare it .PHONY:
.PHONY: all build test clean help
all: build test
clean:
rm -rf build/
Rule of thumb: any target that doesn't produce a file with the same name should be .PHONY. Common phony targets: all, build, test, clean, lint, deploy, help, install.
Silencing commands
By default, make prints each command before executing it. Prefix with @ to suppress:
help:
@echo "Available targets:"
@echo " make build — compile the project"
@echo " make test — run test suite"
@echo " make clean — remove build artifacts"
Error handling
By default, make stops at the first error (non-zero exit code). Sometimes you want to continue regardless — prefix with -:
clean:
-rm -rf build/ # don't fail if build/ doesn't exist
-rm -f *.tmp
Though for rm, the -f flag already suppresses errors, so the - prefix is redundant in this case. More useful for commands like docker rm where the container might not exist.
Debugging
When your Makefile behaves unexpectedly:
# Dry run — show commands without executing
make -n build
# Print the database of rules and variables
make -p | less
# Debug mode — show why make chose to rebuild
make -d build 2>&1 | head -50
make -n is invaluable. I always run it before a complex make deploy to verify what's about to happen.
Real-World Makefiles Beyond C
The most practical use of make in 2026 isn't C compilation — it's as a universal task runner. Here are battle-tested patterns.
Go project
.PHONY: all build test lint clean docker-build
APP_NAME = myapi
VERSION = $(shell git describe --tags --always)
LDFLAGS = -ldflags "-X main.version=$(VERSION)"
all: lint test build
build:
go build $(LDFLAGS) -o bin/$(APP_NAME) ./cmd/$(APP_NAME)
test:
go test ./... -race -cover
lint:
golangci-lint run ./...
clean:
rm -rf bin/
docker-build:
docker build -t $(APP_NAME):$(VERSION) .
Python project
.PHONY: venv install test lint format clean
PYTHON = python3
VENV = .venv
BIN = $(VENV)/bin
venv:
$(PYTHON) -m venv $(VENV)
install: venv
$(BIN)/pip install -r requirements.txt
test: install
$(BIN)/pytest tests/ -v --cov=src
lint: install
$(BIN)/ruff check src/ tests/
format: install
$(BIN)/ruff format src/ tests/
clean:
rm -rf $(VENV) __pycache__ .pytest_cache .coverage
Node.js / Next.js project
.PHONY: dev build lint test clean deploy
dev:
npm run dev
build: lint
npm run build
lint:
node scripts/lint-articles.mjs
npx next lint
test:
npx vitest run
clean:
rm -rf .next node_modules/.cache
deploy: build
npx vercel --prod
help:
@echo "Usage:"
@echo " make dev — start dev server"
@echo " make build — lint + production build"
@echo " make lint — run linters"
@echo " make deploy — build and deploy to Vercel"
Docker Compose workflow
.PHONY: up down logs build restart db-migrate
COMPOSE = docker compose -f docker-compose.yml
up:
$(COMPOSE) up -d
down:
$(COMPOSE) down
logs:
$(COMPOSE) logs -f
build:
$(COMPOSE) build --no-cache
restart: down up
db-migrate:
$(COMPOSE) exec api python manage.py migrate
Multi-language monorepo
.PHONY: all frontend backend test
all: frontend backend
frontend:
$(MAKE) -C frontend build
backend:
$(MAKE) -C backend build
test:
$(MAKE) -C frontend test
$(MAKE) -C backend test
$(MAKE) -C dir runs make in a subdirectory. This pattern scales cleanly — each subdirectory has its own Makefile, and the root Makefile orchestrates them.
FAQ
How do I install make?
On Linux, it's almost certainly already installed. If not: sudo apt install make (Debian/Ubuntu) or sudo dnf install make (Fedora). On macOS, install Xcode Command Line Tools: xcode-select --install. On Windows, use MSYS2 or WSL.
What's the difference between make and cmake?
make is a build executor — it reads a Makefile and runs commands. CMake is a build system generator — it generates Makefiles (or Ninja files, or Visual Studio projects) from a higher-level CMakeLists.txt. For C/C++ projects, CMake generates the Makefile; for task running, you write the Makefile directly.
Can I use spaces instead of tabs?
No. GNU Make requires tabs for recipe indentation. This is a historical design choice from 1976 that can't be changed without breaking backward compatibility. Configure your editor to insert real tabs in files named Makefile. Most editors do this automatically.
How do I make a self-documenting help target?
Add ## description comments to your targets and parse them with awk:
.PHONY: help
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## Build the project
go build -o bin/app ./cmd/app
test: ## Run tests with coverage
go test ./... -cover
clean: ## Remove build artifacts
rm -rf bin/
Running make help outputs a formatted list of all documented targets.
How do I pass arguments to a make target?
make doesn't natively support positional arguments, but you can use variables:
# Override a variable from the command line
make deploy ENV=production
# In the Makefile
deploy:
./deploy.sh --env $(ENV)
Should I use make or npm scripts?
Use both. npm scripts are great for JavaScript-specific tasks (next dev, vitest). make is better for orchestrating cross-tool workflows — running linters, building Docker images, and deploying. A common pattern: make targets that call npm scripts internally.
What's the difference between = and := for variables?
= is recursive — the value is re-expanded every time the variable is referenced. := is simple — the value is expanded once at the point of assignment. Use := by default to avoid surprising behavior. Use = when you intentionally want late evaluation.
How do I handle errors in recipes?
Prefix a command with - to ignore its exit code, or use || true for more explicit intent. For critical commands, make's default behavior (stop on error) is usually what you want.
Wrapping Up
make is one of the oldest tools in the Unix ecosystem, and it's still one of the most useful. The core concepts to internalize:
- Targets, dependencies, recipes — the three-part structure of every rule
- Tabs, not spaces — the eternal gotcha, just configure your editor once
.PHONY— declare it for every target that isn't a real file$@and$^— the two automatic variables you'll use everywhere- Pattern rules with
%— turn 50 rules into one
For C/C++ projects, make's timestamp-based dependency tracking is genuinely powerful. For everything else — Go, Python, Node.js, Docker — it works as a universal task runner that every developer on any platform can use without installing anything extra.
If you're building CLI workflows, combine make with tools from this series: xargs for parallel processing, find for file discovery, grep for searching, and curl for API calls. A Makefile that ties them together is often all the "automation framework" a project needs.