Programming

The Makefile You Should Actually Write

·2 min read

Make is older than C++. It ships everywhere. It is also the single best place to put the dozen commands every project accumulates — build, test, lint, deploy, clean. You don't need to write a build system. You need a task runner that's already installed.

The shape of a usable Makefile

.PHONY: help build test lint clean
.DEFAULT_GOAL := help

help: ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

build: ## Build the binary
	go build -o bin/myapp ./cmd/myapp

test: ## Run tests
	go test ./...

lint: ## Run linters
	golangci-lint run

clean: ## Remove build artifacts
	rm -rf bin/

That self-documenting help target is a standard idiom — type make and you see the menu.

The tab trap

Every recipe line must start with a literal tab. Not spaces. Most editor configs handle this, but if you ever see "missing separator. Stop.", you've got spaces. Force tabs:

# .editorconfig
[Makefile]
indent_style = tab

.PHONY: why and when

make build would normally check whether a file named build exists and is newer than its dependencies. Mark target names that are commands, not files, as .PHONY to skip the check.

Automatic variables

The cryptic single-character variables that make Make feel like APL:

$@   # the target name
$<   # the first prerequisite
$^   # all prerequisites, space-separated
$?   # prerequisites newer than the target
$*   # the stem in a pattern rule

Pattern rules

For repetitive transformations — every .c to a .o, every .md to .html:

SRCS := $(wildcard src/*.c)
OBJS := $(SRCS:src/%.c=build/%.o)

build/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) -c $< -o $@

bin/app: $(OBJS)
	$(CC) $^ -o $@

Variables

Two flavours of assignment: = is recursive (re-expanded every use), := is immediate. Prefer := unless you have a reason. ?= sets a default if not already defined:

CC ?= gcc
CFLAGS := -Wall -Wextra -O2
LDFLAGS := -lpthread

Echo control and errors

Lines starting with @ are not echoed before execution. Lines starting with - ignore non-zero exit codes:

clean:
	@echo "Cleaning..."
	-rm -f *.o
	rm -rf build/

When to graduate

Make is excellent up to maybe 200 lines. Past that, or once you need conditional logic that doesn't fit on one line, switch to whatever your language's native tool is — CMake/Meson for C, Cargo for Rust, npm scripts for Node. But the Makefile stays at the top level as a stable, discoverable entrypoint.

Related