The Purpose Of Makefile
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:
-
allhas norecipeand a bunch ofprerequisites. -
pyproject.tomlandmain.pyshare the samerecipe(both targets listed before the colon) and have noprerequisites. -
testis phony
.PHONYis 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.
makewithouttargetwill run therecipeof the first non-hiddentarget
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
.PHONYmakes 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
targetwins (overwrites) previous definedtargets
Prerequisite Not Defined
t1: t2
make: *** No rule to make target 't2', needed by 't1'. Stop.
prerequisitesdo not need to be defined astargets, 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.”
makehas a bunch of implicit behaviors that make it hard to anticipate its execution logic. Keep it simple.