The Makefile You Should Actually Write
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.