Summary

A makefile “lists each of the non-source files [targets] and how to compute it [recipe] from other files [prerequisites]”, so that make “controls the generation of executables and other non-source files”.

A recipe is invoked by executing make <targets> if the prerequisites are newer than the targets.

A phony target will not check for newness.

Be careful with directories as targets or prerequisites, as their newness cannot be easily determined.

Introduction

I’ve been using makefiles for quite a while, without really understanding their purpose. Before using them, I used to store shell-scripts in a scripts folder, which was working fine. Then I started using makefiles for their consistency and easier execution via make. I started to get annoyed by make’s limitations regarding multi-line statements and usage of environment variables, so I wondered what the real purpose of this tool was. I summarized my main learnings above, but want to dive a bit deeper, starting with a:

Comprehensive Example

I purposefully do not explain basic syntax explanations, because there are surely better sources for this. Instead, let’s consider the following makefile. It is situated in a uv context, to create some lock files and run tests.

Feel free to copy it into an empty folder to follow along.

.PHONY: test

all: requirements.txt uv.lock pylock.toml dist

requirements.txt: pyproject.toml
	uv pip compile --format requirements.txt --output-file requirements.txt pyproject.toml

pylock.toml: pyproject.toml
	uv pip compile --format pylock.toml --output-file pylock.toml pyproject.toml

uv.lock: pyproject.toml
	uv lock

pyproject.toml main.py:
	uv init
	uv add ipykernel --group dev
	uv add pyyaml

dist: pyproject.toml
	uv build

test: main.py
	uv run python -m doctest -v main.py

Targets

In the example, we have a bunch of regular targets, like requirements.txt or uv.lock. We also have some special cases:

  • all has no recipe and a bunch of prerequisites.

  • pyproject.toml and main.py share the same recipe (both targets listed before the colon) and have no prerequisites.

  • test is phony

.PHONY is itself always a phony target

Usage

making all

By invoking

make

a bunch of files and (hidden) folders will be created in your directory. A second run will echo:

make: Nothing to be done for 'all'

If you execute

make all

the exact same output will be shown.

This is roughly the same as executing:

make pyproject.toml
make requirements.txt
make uv.lock
make pylock.toml
make dist

which now echoes:

make: 'pyproject.toml' is up to date.
make: 'requirements.txt' is up to date.
make: 'uv.lock' is up to date.
make: 'pylock.toml' is up to date.
make: 'dist' is up to date.

make without target will run the recipe of the first non-hidden target

Updating a target

If we now run

uv add requests
make requirements.txt

we get the output

# This file was autogenerated by uv via the following command:
#    uv pip compile --format requirements.txt --output-file requirements.txt pyproject.toml
certifi==2025.11.12
    # via requests
charset-normalizer==3.4.4
    # via requests
idna==3.11
    # via requests
pyyaml==6.0.3
    # via test (pyproject.toml)
requests==2.32.5
    # via test (pyproject.toml)
urllib3==2.5.0
    # via requests

(I’m running in a folder called test)

By running uv add, we changed the content of pyproject.toml, which is a prerequisite of requirements.txt. By invoking make requirements.txt the recipe was executed due to the fact that pyproject.toml was newer than requirements.txt.

If you repeat the make requirements.txt invocation, it will again echo make: 'requirements.txt' is up to date., because requirements.txt is newer than pyproject.toml.

Executing a phony target

we can execute

make test

as often as we like. it will always echo

uv run python -m doctest -v main.py
2 items had no tests:
    main
    main.main
0 tests in 2 items.
0 passed.
Test passed.

even if we do

touch test

It will still run.

If we now remove test from .PHONY, we get

make: 'test' is up to date.

add it again to .PHONY and the test can run again.

adding to .PHONY makes sure the recipe will run in any case.

Some Other Learnings

Overwriting

What happens if we define a target multiple times?

t1:
    echo "first recipe"
t1:
    echo "second recipe"
makefile:28: warning: overriding recipe for target 't1'
makefile:26: warning: ignoring old recipe for target 't1'
echo "second recipe"
second recipe

last target wins (overwrites) previous defined targets

Prerequisite Not Defined

t1: t2
make: *** No rule to make target 't2', needed by 't1'.  Stop.

prerequisites do not need to be defined as targets, but this will result in errors if the prerequisite file doesn’t exist.

Target Without Recipe

Consider the previous comprehensive example in an empty folder and the following execution:

touch requirements.txt
touch pyproject.toml
touch uv.lock
touch pylock.toml
touch dist
make all

My expectation would be that something will run, because there’s no file called all and all is not part of .PHONY. But we get:

make: Nothing to be done for 'all'.

On the other hand, if we run:

touch all # add this
touch requirements.txt
touch pyproject.toml
touch uv.lock
touch pylock.toml
touch dist
make all

This will trigger make to attempt rebuilding all (which will result in an error, but that is not my point here).

This is because: “If a rule has no prerequisites or recipe, and the target of the rule is a nonexistent file, then make imagines this target to have been updated whenever its rule is run.”

make has a bunch of implicit behaviors that make it hard to anticipate its execution logic. Keep it simple.