make and Makefiles — Automating Tasks Beyond C Projects
make was built to compile C programs, but it’s evolved into something far more useful: a self-documenting task runner that lives in your project root and works on every Unix system without dependencies. Whether you’re building a Go binary, running a test suite, pushing Docker images, or generating docs, a Makefile makes the command discoverable, repeatable, and consistent across machines.
The basic structure
A Makefile is a collection of rules. Each rule has a target, optional prerequisites, and a recipe:
target: prerequisites
recipe
recipe
The recipe lines must be indented with a tab (not spaces). This is the single most common Makefile gotcha.
A minimal example:
build:
go build -o bin/myapp ./cmd/myapp
test:
go test ./...
clean:
rm -rf bin/
Run a target with:
$ make build
go build -o bin/myapp ./cmd/myapp
$ make test
go test ./...
ok myapp/pkg/api 0.342s
ok myapp/pkg/db 0.128s
Phony targets
If a file named build or test exists in the project root, make will think the target is already up-to-date and skip it. Declare targets as .PHONY to prevent this:
.PHONY: build test clean lint run
build:
go build -o bin/myapp ./cmd/myapp
test:
go test -race ./...
lint:
golangci-lint run
clean:
rm -rf bin/
Almost every task-runner Makefile should have .PHONY for all non-file targets.
Variables
APP_NAME = myapp
BIN_DIR = bin
CMD_PATH = ./cmd/$(APP_NAME)
.PHONY: build
build:
go build -o $(BIN_DIR)/$(APP_NAME) $(CMD_PATH)
Variables can also be set at the command line, which overrides the Makefile default:
$ make build APP_NAME=myapp-debug
Environment variables
Make inherits all shell environment variables, so $(HOME), $(PATH), and $(CI) work without declaration.
A practical Makefile for a Python project
VENV = .venv
PYTHON = $(VENV)/bin/python
PIP = $(VENV)/bin/pip
.PHONY: install test lint format clean run
install:
python3 -m venv $(VENV)
$(PIP) install -r requirements.txt -r requirements-dev.txt
test:
$(VENV)/bin/pytest tests/ -v
lint:
$(VENV)/bin/ruff check .
$(VENV)/bin/mypy src/
format:
$(VENV)/bin/ruff format .
run:
$(PYTHON) -m myapp
clean:
rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache
$ make install
$ make test
$ make lint
A practical Makefile for a Docker project
IMAGE = myapp
TAG ?= latest
REGISTRY = ghcr.io/mukulkadel
.PHONY: build push run clean
build:
docker build -t $(REGISTRY)/$(IMAGE):$(TAG) .
push: build
docker push $(REGISTRY)/$(IMAGE):$(TAG)
run:
docker run --rm -p 8080:8080 $(REGISTRY)/$(IMAGE):$(TAG)
clean:
docker rmi $(REGISTRY)/$(IMAGE):$(TAG) || true
The ?= operator sets a default that the caller can override:
$ make push TAG=v1.2.3
Prerequisites chain targets
If push depends on build, declare it as a prerequisite:
push: build
docker push ...
Now make push automatically runs build first. If build was already done and nothing changed, make skips it (for file targets; for .PHONY targets it always reruns).
Silent commands with @
By default, make prints each command before running it. Prefix a line with @ to suppress the echo:
test:
@echo "Running tests..."
@go test ./...
Without @:
echo "Running tests..."
Running tests...
go test ./...
With @:
Running tests...
Error handling
By default, make stops if any recipe command exits non-zero. To intentionally ignore an error (e.g., rm when the file may not exist), prefix the line with -:
clean:
-rm -rf bin/
-rm -rf .cache/
A help target every project should have
Add a help target that documents all targets — great for teams and for your future self:
.PHONY: help
help:
@echo "Available targets:"
@echo " make install Install dependencies"
@echo " make test Run test suite"
@echo " make lint Run linters"
@echo " make build Build binary"
@echo " make clean Remove build artifacts"
A fancier pattern auto-generates help from comments in the Makefile itself:
## install: Install project dependencies
install:
pip install -r requirements.txt
## test: Run the full test suite
test:
pytest tests/
.PHONY: help
help:
@grep -E '^## ' Makefile | sed 's/## //'
$ make help
install: Install project dependencies
test: Run the full test suite
Default target
The first target in a Makefile is the default — running make with no arguments runs it. A common convention is to make help or build the default:
.DEFAULT_GOAL := help
Or just put your preferred default first.
make vs shell scripts
You could write all of this as shell scripts. The advantage of make is that the targets are named, declarable, and composable — you get a single entry point (make <thing>), dependency tracking, and convention that new contributors already know. The disadvantage is the tab-vs-space rule, limited control flow, and the fact that complex logic in a Makefile gets unreadable fast.
The practical rule: use make as a thin orchestrator that calls scripts. Keep the scripts in scripts/ for complex logic, and keep the Makefile as the entry point.
Conclusion
make is one of those tools that pays back its learning cost on the first project you use it for. A well-written Makefile with a help target turns a directory of scripts into a self-documenting command interface that any developer can pick up with make help. Start with five targets — install, build, test, lint, clean — and grow from there. The tab indentation is the only sharp edge; once you know it’s there, everything else is straightforward.