Sandro Turriate

Coder, cook, explorer

The Makefile is the programmer's README

Oct 3, 2020

Six years ago I wrote a love letter to the Makefile. And, you know what? Six years later I'm still using them — not as some heavyweight C build system, but as fast documentation and a place to hang repeatable processes. Sometimes a Makefile is just a pointer to a script that lives somewhere else. Either way, it has become my favourite piece of tooling.

The Makefile is the programmer's README. A good README.md tells you what the project is. A good Makefile tells you how to actually do things with it — the commands, the order, the gotchas. And unlike a README, which drifts out of date the moment someone refactors a command, the Makefile is the command. It cannot lie, because the thing it documents is the thing that runs.

Why I reach for make build even when go build is trivial

Here's the confession: I write Go. The incantation to build the thing is literally go build. There is nothing hard about it. I can type it in under a second. So why do I stubbornly type make build instead?

  • Muscle memory across projects. Every project — Go, Python, Node, whatever — has a build, a test, a run, a clean. I don't have to remember whether this specific repo uses go run ./cmd/server or npm run dev or uvicorn app.main:app. I just type make build, and the Makefile translates for me.
  • The "you forgot a step" prevention. A real build usually isn't just one command. It's go generate, then lint, then build, then maybe copy a file somewhere. By hand, you'll forget one of those. In the Makefile, they're baked in.
  • It's the on-ramp. A new person (or future-me, six months from now) should be able to clone the repo, read the Makefile top to bottom, and know every common task in thirty seconds.
  • One convention to rule them all. The Makefile becomes the single, predictable surface for the project's ritual. Everything else is an implementation detail.

This is what I meant by "the programmer's README." When in doubt, list your targets: make setup, make build, make test, make lint, make dev, make clean. That's the documentation, and it's executable.

Makefiles as pointers to scripts

Not everything needs to live in Make's own syntax. A brilliant pattern is to keep your real logic in a shell script and let Make be a friendly, discoverable pointer to it:

1
2
3
4
5
6
7
8
setup:
    ./scripts/setup.sh

deploy:
    ./scripts/deploy.sh prod

backup-db:
    ./scripts/backup.sh

Now someone can do make setup without ever knowing your repo has a scripts/ folder, let alone which script does what. The Makefile is the index; the scripts are the chapters. This keeps the Makefile small, keeps complexity in a real language where it belongs, and keeps the surface area friendly.

Make variables, dependencies, and the rest

The humble basics still earn their keep. Variables keep one source of truth for things like images, versions, and flags:

1
2
3
4
5
VERSION ?= $(shell git describe --tags --always)
IMAGE = myapp:$(VERSION)

build:
    docker build -t $(IMAGE) .

Dependencies give you ordering for free, and — this is my favourite trick — they give you a free "process checklist." Every target's prerequisites read like a recipe:

1
2
3
4
5
test: lint vet
    go test ./...

ci: test build
    ./scripts/ci-notify.sh

Now make ci runs lint, then vet, then tests, then build, then notifies. In the right order, every time. A README paragraph describing the CI pipeline would be a lie within a week; the Makefile can't drift, because it is the pipeline.

Now, about .PHONY — should you write it?

You said it: you almost never write .PHONY, because make "seems to work" without it. And you're right that it usually works — until it bites you. Let's be precise about why, because there are two distinct reasons, and they matter in different situations.

A "phony" target is one that is not the name of a real file — it's just a name for a recipe you want to run on demand. GNU make's manual says a target like clean normally works even without .PHONY because rm never creates a file called clean. The recipe runs every time you say make clean because the target is always considered out of date (no file exists to compare timestamps against).

So why declare it? Two reasons, straight from the manual:

Reason 1: Avoiding a conflict with a real file

The classic trap. Your clean recipe could be a no-op if anything ever creates a file literally named clean in that directory — say, a stray log, an artifact, or a file generated by a tool. Once that file exists, make sees the clean target with no prerequisites, decides it's "up to date," and skips the recipe entirely. No more cleaning. The failure mode is silent and confusing.

1
2
3
.PHONY: clean
clean:
    rm -rf build dist

With .PHONY: clean, make will run the recipe no matter what's sitting on disk. This is the "belt and suspenders" reason — it makes the rule immune to the file-existing footgun.

Reason 2: Performance

This is the one most people are surprised by. Declaring a target .PHONY tells make to skip the implicit rule search for it. Without that declaration, make may spend time trying to figure out whether build or test could be built via some built-in suffix/pattern rule before concluding it's just an out-of-date target. For a target that's obviously not a file, that search is wasted work. Declaring phoniness skips it — a small but real performance win, especially in big makefiles with many targets.

So: writing .PHONY isn't about "making it work" (it usually already does), it's about hardening the target against a real-file collision and skipping wasted work. Both are good reasons to bother.

When you should not declare .PHONY

Here's the nuance a lot of tutorials skip. A phony target should not be a prerequisite of a real file target. If it is, its recipe runs every single time make considers that file — which probably isn't what you want. Keep phony targets as goals, not as dependencies of actual files.

Also: don't mark real output targets as phony. If your rule does create a file — say a compiled binary, a bundle, or a generated doc — you want make to compare timestamps and skip the work when nothing changed. That's the whole point of Make. Marking that phony throws away the "only rebuild when stale" behaviour forever.

And one more: phoniness is not inherited. Declaring all as phony does not make its prerequisites phony. Declare each one that needs it.

A practical rule of thumb

Here's how I decide, and it covers 95% of cases:

  • Target is a name for an action (build, test, clean, deploy, lint) that creates no file → declare it .PHONY. It's cheap insurance and a performance win.
  • Target produces an artifact (a binary, a tarball, a generated file) → don't declare it phony, so make can skip it when it's already up to date.

And my favourite practical trick: you can just list everything at the top of the file in one line:

1
.PHONY: all build test lint vet clean dev deploy setup

One line, all your action targets, hardened and fast. You don't need a .PHONY above every single rule.

The Makefile is documentation you can run

That's the whole pitch. A Makefile is the rare artifact that is simultaneously documentation, an executable checklist, and a repeatable process. It doesn't go stale the way prose does, because the words are the actions. It gives newcomers — and future-you — a single, predictable surface for the project's rituals.

So go ahead: keep reaching for make build. It's not lazy, it's smart. And while you're at it, spend the one extra line on .PHONY — your future self, staring at a silent "no work to do," will thank you.