# Make and Makefiles

> The 50-year-old build tool that still runs everything: targets, prerequisites, and recipes - a dependency graph that rebuilds only what changed.


---

# Make and Makefiles

You typed `make` once, it printed a wall of compiler noise, and you nodded like you understood. Then someone handed you a project where `make test` and `make build` were the whole workflow, and a stray space gave you `missing separator. Stop.` with no clue why. Make feels like ancient runes, but underneath it is one small, sharp idea you can hold in your head. This guide hands you that idea, then the everyday moves, then the traps that bite everyone once.

## How to read this

Read the three phases in order. Phase 1 is the mental model - the one picture that makes every Makefile readable. Phase 2 is the daily driver: writing targets, using Make as a task runner, and the variables that keep recipes clean. Phase 3 is where it breaks: the infamous tab, stale builds, and the production reality of why this tool outlived almost everything around it. Type the examples. A Makefile you ran beats one you skimmed.

## The phases

1. [Phase 1: The Dependency Graph in Your Head](01-the-dependency-graph.md) - what Make actually is and why it exists
2. [Phase 2: Targets, Tasks, and Variables](02-targets-tasks-variables.md) - how you really use it day to day
3. [Phase 3: The Tab, Stale Builds, and Why It Endures](03-gotchas-and-why-it-endures.md) - where it bites and why it survived


---

# The Dependency Graph in Your Head

Here is the reality Make was born into. You have source files. You run a compiler. It chews on the source and spits out something else - a binary, a bundle, a PDF. Compiling everything takes a while. So when you change one file, you want to rebuild only the things that depend on that file, and leave the rest alone. Doing that bookkeeping by hand is miserable and easy to get wrong.

Make is the machine that does that bookkeeping for you. That is the whole idea. Once you see it as bookkeeping over a graph, every Makefile stops being runes and starts being readable.

## Three words: target, prerequisites, recipe

A Makefile is a list of rules. Every rule has the same shape:

```makefile
target: prerequisites
	recipe
```

- The **target** is the thing you want to exist - usually a file, like `app` or `report.pdf`.
- The **prerequisites** are the things the target is made from - the files it depends on.
- The **recipe** is the shell command that builds the target from the prerequisites.

Read it out loud: "To make `target`, I need `prerequisites`, and here is how." That sentence is the entire model.

```makefile
report.pdf: report.md
	pandoc report.md -o report.pdf
```

*What just happened:* you declared that `report.pdf` is built from `report.md` by running `pandoc`. You have not run anything yet - you have described a relationship.

## The one decision Make makes

When you run `make report.pdf`, Make does not blindly run the recipe. It asks one question: **is the target older than any of its prerequisites?**

It checks file modification timestamps. If `report.md` was modified more recently than `report.pdf` - or if `report.pdf` does not exist yet - the target is stale, so Make runs the recipe. If `report.pdf` is newer than `report.md`, nothing changed that matters, so Make does nothing and tells you so.

```console
$ make report.pdf
pandoc report.md -o report.pdf

$ make report.pdf
make: 'report.pdf' is up to date.
```

*What just happened:* the first run built the PDF because it did not exist. The second run did nothing - `report.pdf` is now newer than `report.md`, so Make saw no work to do. Edit `report.md` and run again, and it rebuilds. That timestamp comparison is the engine of the entire tool.

## It is a graph, not a list

Prerequisites can themselves be targets of other rules. That is what turns a flat list into a graph.

```makefile
site.zip: index.html style.css
	zip site.zip index.html style.css

index.html: index.md
	pandoc index.md -o index.html
```

Ask Make for `site.zip` and it works backward. To build `site.zip` it needs `index.html`. Is `index.html` itself a target with its own prerequisites? Yes - it depends on `index.md`. So Make checks that branch first.

```text
site.zip
├── index.html  ← built from index.md
└── style.css
```

*What just happened:* Make built a small dependency tree in memory, then walked it from the leaves up. It rebuilds `index.html` only if `index.md` changed, then rebuilds `site.zip` only if either prerequisite ended up newer. Change one Markdown file deep in a big project and Make rebuilds exactly the chain it touches - and nothing else. That selective rebuild is why Make scaled to enormous codebases decades before anything fancier existed.

> The order you write rules in does not matter for the graph. Make reads the whole file, builds the graph, then decides what to run. The only special rule is the first one - that is the default target when you run bare `make`.

## Why this beats a shell script

You could write a `build.sh` that runs every command top to bottom. It would work. But it would do *all* the work *every* time, even when nothing changed. On a large project that is the difference between a one-second rebuild and a five-minute one.

Make's superpower is the incremental rebuild: describe the dependencies once, and it figures out the minimum work to bring everything up to date. You declare *relationships*; a plain script encodes a fixed *sequence*. For builds, relationships win - because the right sequence changes depending on what you edited, and Make recomputes it every time from the timestamps.

For builders: this is the same core idea that later tools (Bazel, Ninja, Gradle's incremental compilation) refined and scaled. Learn it here and the rest read like dialects. See [/guides/build-and-release-basics](/guides/build-and-release-basics) for where build tools fit in the bigger release picture.

```quiz
[
  {
    "q": "When you run `make foo`, what determines whether Make runs the recipe?",
    "choices": [
      "It always runs the recipe every time",
      "Whether the target is missing or older than any prerequisite",
      "Whether the recipe has changed since last run",
      "Whether you passed the --force flag"
    ],
    "answer": 1,
    "explain": "Make compares modification timestamps: if the target is missing or older than a prerequisite, it is stale and the recipe runs. Otherwise Make does nothing."
  },
  {
    "q": "In the rule `app: main.c utils.c`, what is `app`?",
    "choices": [
      "A prerequisite",
      "A recipe",
      "The target - the thing to be built",
      "A variable"
    ],
    "answer": 2,
    "explain": "The name before the colon is the target. The names after the colon (main.c utils.c) are its prerequisites."
  },
  {
    "q": "Why is Make often better than a plain build.sh for a large project?",
    "choices": [
      "It runs commands in parallel by default",
      "It rebuilds only what changed, instead of redoing all work every time",
      "It is written in a faster language",
      "It does not need a shell installed"
    ],
    "answer": 1,
    "explain": "A script runs its whole sequence every time. Make uses the dependency graph and timestamps to do the minimum work needed to bring targets up to date."
  }
]
```


---

# Targets, Tasks, and Variables

Here is the twist that surprises people: most Makefiles you meet in the wild are not compiling C. They are running tasks. `make test`, `make build`, `make deploy`, `make lint`. The dependency-graph engine from Phase 1 turns out to be a clean, language-agnostic command runner, and that is how the modern world actually uses it. This phase is the daily driver: writing those task targets, keeping recipes readable with variables, and the small set of moves you will reach for every day.

## Make as a task runner

A target does not have to produce a file. You can write a rule whose whole job is to *do something*:

```makefile
test:
	pytest tests/

lint:
	ruff check .

run:
	python app.py
```

```console
$ make test
pytest tests/
======== 42 passed in 1.2s ========
```

*What just happened:* `make test` ran the recipe. There is no file named `test`, so Make sees no `test` file on disk, concludes the target is "missing," and runs the recipe every time. That accidental behavior is exactly what a task runner wants - but it has a sharp edge, which is why phony targets exist (next section).

The win over typing the raw commands: discoverability and muscle memory. Every project speaks the same dialect. New to a repo? Try `make test`, `make build`, `make run`. One vocabulary across every language and stack. That consistency is half of why Make refuses to die.

## Phony targets: tell Make these are not files

There is a trap hiding in the task-runner pattern. Suppose someone creates a file or folder named `test` in your project. Now `make test` sees that the `test` "target" exists and is newer than its (zero) prerequisites - so it decides everything is up to date and **refuses to run your tests**.

```console
$ touch test
$ make test
make: 'test' is up to date.
```

*What just happened:* the stray file named `test` fooled Make's timestamp logic. Make thought the target was already built. This is a real, confusing bug that bites people.

The fix is `.PHONY`. It tells Make "this target is a task name, not a file - never check the disk for it, always run the recipe."

```makefile
.PHONY: test lint run

test:
	pytest tests/

lint:
	ruff check .

run:
	python app.py
```

*What just happened:* declaring the targets phony makes Make skip the file check entirely. The recipes now always run, regardless of what files happen to exist. **Rule of thumb:** any target that is a verb (a task) rather than a noun (a file) should be listed in `.PHONY`.

## Variables: name things once

Repetition in a Makefile is a bug waiting to happen. Variables fix that. Define with `=` or `:=`, expand with `$(NAME)`.

```makefile
PYTHON := python3
SRC := src/
TESTS := tests/

.PHONY: test format

test:
	$(PYTHON) -m pytest $(TESTS)

format:
	$(PYTHON) -m black $(SRC) $(TESTS)
```

*What just happened:* the interpreter and the directories live in one place each. Change `PYTHON := python3` to `PYTHON := python3.12` once and every recipe follows. Note `$(PYTHON)` with parentheses - a bare `$P` would mean the variable `P` followed by a literal `YTHON`, which is a classic silent mistake.

> `:=` expands the right-hand side **once, immediately**. Plain `=` is lazy - it re-expands every time the variable is used, which can surprise you when the value references other variables that change. When in doubt, reach for `:=`. It is the predictable one.

## Automatic variables: stop repeating the target name

When a rule builds a file, you often need to name the target and its prerequisites inside the recipe. Make gives you shorthands so you never hardcode them:

- `$@` - the target (the thing being built)
- `$<` - the first prerequisite
- `$^` - all prerequisites, space-separated

```makefile
app: main.o utils.o
	gcc -o $@ $^

%.o: %.c
	gcc -c $< -o $@
```

*What just happened:* in the first rule, `$@` is `app` and `$^` is `main.o utils.o`. In the pattern rule `%.o: %.c`, the `%` matches any stem - so `main.o` is built from `main.c` with `$<` as `main.c` and `$@` as `main.o`. One rule covers every `.c` file. Automatic variables keep recipes short and stop the copy-paste errors that come from typing filenames twice.

## A real, small Makefile

Putting the pieces together - this is roughly what you will see at the top of countless repos:

```makefile
.PHONY: install test lint build clean

install:
	pip install -e .

test: install
	pytest

lint:
	ruff check .

build: test
	python -m build

clean:
	rm -rf dist/ build/ *.egg-info
```

*What just happened:* the graph still does real work even with task targets. `make build` lists `test` as a prerequisite, and `test` lists `install` - so `make build` runs install, then tests, then the build, in that order, automatically. You declared the *dependencies between tasks* and Make sequenced them. That is the Phase 1 engine quietly running underneath a friendly task vocabulary.

In the wild: a habit worth stealing is making the first target a `help` that lists the others, so a newcomer running bare `make` gets a menu instead of a surprise. For the shell mechanics behind these recipes - quoting, pipes, exit codes - see [/guides/the-terminal-and-shell](/guides/the-terminal-and-shell).

```quiz
[
  {
    "q": "Why should task targets like `test` and `build` be listed in `.PHONY`?",
    "choices": [
      "It makes the recipes run faster",
      "It stops a same-named file on disk from fooling Make into skipping the recipe",
      "It is required syntax for any target",
      "It enables parallel execution"
    ],
    "answer": 1,
    "explain": "Without .PHONY, a file named `test` would make Make think the target is up to date and skip the recipe. .PHONY tells Make the target is a task, not a file."
  },
  {
    "q": "In the rule `app: main.o utils.o` with recipe `gcc -o $@ $^`, what does `$^` expand to?",
    "choices": [
      "app",
      "main.o",
      "main.o utils.o",
      "the first prerequisite only"
    ],
    "answer": 2,
    "explain": "`$^` is all prerequisites space-separated, so `main.o utils.o`. `$@` is the target (app) and `$<` is the first prerequisite (main.o)."
  },
  {
    "q": "What is the practical difference between `:=` and `=` when defining a variable?",
    "choices": [
      "`:=` is for numbers, `=` is for strings",
      "`:=` expands its value once immediately; `=` re-expands lazily on each use",
      "They are identical",
      "`=` is deprecated and errors in modern Make"
    ],
    "answer": 1,
    "explain": "`:=` evaluates the right-hand side once at definition time. Plain `=` is recursively expanded every time the variable is referenced, which can surprise you."
  }
]
```


---

# The Tab, Stale Builds, and Why It Endures

You understand the graph and you can write task targets. Now for the part nobody warns you about until you have already lost an hour to it. Make has a handful of legendary sharp edges - one of them is over fifty years old and still claims victims every day. This phase walks you through the traps so you recognize them on sight, then steps back to ask the real question: why is a tool this quirky still everywhere?

## The tab. Always the tab.

Recipe lines must be indented with a literal **TAB character**, not spaces. This is the single most infamous gotcha in all of Make. Your editor may have helpfully inserted spaces, and the error you get does not mention tabs at all:

```console
$ make build
Makefile:2: *** missing separator.  Stop.
```

*What just happened:* line 2 was indented with spaces instead of a tab, so Make could not tell it was a recipe line. "missing separator" is Make's cryptic way of saying "I expected a tab here." This is the bug everyone hits at least once, and the error message gives you almost nothing to go on.

The fix: make sure recipe lines start with a real tab. Most editors can show whitespace or be told to keep tabs in Makefiles. If you suspect a file, you can ask Make to point at the line, or check for stray spaces:

```console
$ cat -A Makefile | head -3
build:$
        gcc -o app main.c$
```

*What just happened:* `cat -A` reveals whitespace. A correct recipe line shows `^I` (a tab) at the start; if you instead saw leading spaces, that is your culprit. The TAB requirement is a historical accident from 1976 that the maintainers have kept for compatibility - generations of source files would break if it changed.

> Each recipe line runs in its **own separate shell**. So `cd build` on one line does not affect the next line - the directory change is gone when that shell exits. To chain them, put both commands on one line joined with `&&`, or use a backslash to continue: `cd build && cmake ..`.

## Stale builds: when the graph lies

Make's whole correctness rests on prerequisites being accurately declared. If a target secretly depends on a file you did not list, Make will not rebuild when that file changes - and you get a *stale build*: output that does not match your source, with no error at all.

```makefile
app: main.c
	gcc -o app main.c config.h
```

*What just happened:* the recipe reads `config.h`, but `config.h` is not in the prerequisites. Edit `config.h` and run `make` - Make sees `main.c` unchanged, declares `app` up to date, and skips the rebuild. Your binary now uses the old `config.h`. The build is silently wrong.

The cure is to list every input as a prerequisite:

```makefile
app: main.c config.h
	gcc -o app main.c config.h
```

When a stale build has you cornered and you need a clean slate, the blunt instrument is to wipe the outputs and rebuild - which is exactly why that `clean` target from Phase 2 exists:

```console
$ make clean && make
```

*What just happened:* `clean` deleted the build outputs, so every target is now missing and Make rebuilds everything from scratch. Reach for this when you suspect the graph is lying; fix the missing prerequisite so you do not have to.

## A few more edges worth knowing

```console
$ make -n build
gcc -o app main.c config.h
```

*What just happened:* `-n` (dry run) prints the recipes Make *would* run without running them. It is the safest way to understand or debug a Makefile before you let it loose. Pair it with `make -j4` (run up to 4 recipes in parallel) once your prerequisites are accurate - parallelism only works if the graph is correct, because Make uses the dependencies to know what is safe to run at the same time.

By default, if any recipe line returns a non-zero exit code, Make stops immediately. That is usually what you want - a failed compile should not march on to the link step. If you genuinely want to ignore a failure (say, a `rm` of a file that may not exist), prefix the command with `-`:

```makefile
clean:
	-rm dist/app
	rm -rf build/
```

*What just happened:* the leading `-` on the first line tells Make to keep going even if that `rm` fails. The second line has no `-`, so a failure there still stops the build. Use this sparingly - swallowing errors is how stale and broken builds hide.

## Why it endures

Step back and look at what Make actually is: a tiny, dependency-aware command runner that ships on essentially every Unix-like machine, depends on nothing, and uses a model you can hold in your head. That combination is rare.

- **It is everywhere.** No install step, no version manager, no lockfile. If there is a terminal, there is probably a `make`.
- **It is universal.** The graph engine does not care whether you compile C, render PDFs, or run linters. One tool, every stack.
- **The model is small.** Targets, prerequisites, recipes, timestamps. You learned it in Phase 1 and it has not grown since.
- **It is upfront about being a wrapper.** Newer tools hide your commands behind layers; a Makefile shows you the exact shell commands it runs. When something breaks, you can read it.

The quirks are real - the tab, the per-line shells, the silent stale builds. But for "run these tasks, rebuild only what changed," nothing has matched its blend of ubiquity and simplicity in five decades.

For builders: even when a project's real build lives in a heavier tool, a thin Makefile on top - `make test`, `make deploy` - is a kindness to everyone who clones the repo. It gives them one front door regardless of what is behind it. That habit slots neatly into the release flow in [/guides/build-and-release-basics](/guides/build-and-release-basics).

```quiz
[
  {
    "q": "You get `Makefile:2: *** missing separator. Stop.` What is the most likely cause?",
    "choices": [
      "A missing prerequisite",
      "The recipe line is indented with spaces instead of a tab",
      "A typo in the target name",
      "The shell is not installed"
    ],
    "answer": 1,
    "explain": "Recipe lines must start with a literal TAB. Spaces produce the cryptic 'missing separator' error. This is Make's most infamous gotcha."
  },
  {
    "q": "A target's recipe reads a file that is NOT listed in its prerequisites. What happens when that file changes?",
    "choices": [
      "Make errors and refuses to build",
      "Make rebuilds the target anyway",
      "Make may skip the rebuild, producing a silently stale build",
      "Make warns you about the missing prerequisite"
    ],
    "answer": 2,
    "explain": "Make only compares declared prerequisites. An undeclared input that changes won't trigger a rebuild, so the output silently goes stale. List every input."
  },
  {
    "q": "What does `make -n build` do?",
    "choices": [
      "Runs the build with no output",
      "Prints the recipes it would run, without executing them (dry run)",
      "Forces a rebuild ignoring timestamps",
      "Runs the build with no parallelism"
    ],
    "answer": 1,
    "explain": "`-n` is a dry run: it prints the commands Make would execute without running them - the safest way to understand or debug a Makefile."
  }
]
```
