32blogby StudioMitsu

Makefile Practical Guide: Automate Anything with make

Learn make and Makefile from scratch. Covers targets, dependencies, variables, pattern rules, .PHONY, and real-world Makefiles for Go, Python, Docker, and Node.js projects.

12 min read
On this page

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:

makefile
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:

makefile
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:

  1. Does build/app exist?
  2. Is it newer than all three source files?
  3. If yes → do nothing. If no → run gcc.

Multiple targets and the default

make runs the first target by convention. This is typically all:

makefile
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:

makefile
# 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

makefile
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
makefile
# := 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:

VariableMeaningExample
$@Target namebuild/app
$<First prerequisitesrc/main.c
$^All prerequisites (deduped)src/main.c src/utils.c
$?Prerequisites newer than targetchanged files only
$*The stem matched by %see pattern rules
makefile
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

makefile
# 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 $*:

makefile
%.o: %.c
	@echo "Compiling $* → $@"
	$(CC) $(CFLAGS) -c $< -o $@
# For foo.o: "Compiling foo → foo.o"

Combining with wildcard and patsubst

makefile
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

makefile
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?

bash
$ 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:

makefile
.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:

makefile
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 -:

makefile
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:

bash
# 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

makefile
.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

makefile
.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

makefile
.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

makefile
.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

makefile
.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:

makefile
.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:

bash
# 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.