Make for Python with uv: history, concepts, and a practical cookbook
Automate Python workflows with uv and Make
Modern projects grow a long list of commands. Install this. Format that. Run tests. Build docs. Deploy. You can keep all of that in your head or in a scratch file, or you can name the commands and let a tool manage the order and the work. That tool is make.
A short history of make
make was created in 1976 by Stuart Feldman at Bell Labs to solve a simple problem: rebuild only what changed. Early C projects compiled many files into programs. Recompiling everything each time was slow. make used file timestamps and a simple dependency graph to do the least work necessary. That idea spread far beyond C. It still works for data workflows, docs, web assets, and Python.
You write rules that say what a target depends on and how to build it. make decides what to run based on what changed. The core is still the same today.
Core structure and terms
The shape of a rule looks like this:
target: prerequisites | order_only_prerequisites
recipe line 1
recipe line 2
- “target”: The thing to build. Often a file. It can also be a named command that is not a file.
- “prerequisites”: What the target depends on. If any prerequisite is newer than the target, the recipe runs.
- “order only prerequisites”: Things that must exist first but do not force a rebuild if they change. Put them after a
|. - “recipe”: The shell commands that build the target. Recipe lines must start with a tab.
Useful terms and variables:
- “phony target”: A name that does not correspond to a file. Mark these with
.PHONYsomakealways runs them. - “automatic variables”: Inside a recipe,
$@is the target name,$<is the first prerequisite, and$^is the full prerequisite list. - “assignment operators”:
:=expands immediately when defined.=expands later when used.?=sets a default only if the variable is not already set.
- “functions”:
makehas functions like$(shell ...),$(wildcard ...),$(if ...),$(subst ...), and more.
We will use uv for Python throughout. uv is a fast Python package and environment manager. It creates a local virtual environment, installs dependencies, and can run tools without activating the environment. That keeps recipes simple and reliable.
A minimal Python and uv Makefile
Start with a small, readable Makefile that you can grow over time.
.PHONY: help
# Variables
UV ?= uv
VENV ?= .venv
PYTHON := $(VENV)/bin/python
REQS ?= requirements.txt
help:
@printf "Available commands\n\n"
@awk 'BEGIN{FS=":.*##"}; /^[a-zA-Z0-9_-]+:.*##/{printf "%-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: venv install
# Create the environment if missing, but do not force rebuilds when it changes
venv: | $(VENV) ## Create local virtual environment
$(VENV):
$(UV) venv --seed --python=python3 $(VENV)
# Install dependencies using uv into the pinned venv
install: $(VENV) $(REQS) ## Install Python dependencies with uv
$(UV) pip install -p $(VENV) -r $(REQS)
Run make help to see commands, then make venv, then make install.
This already shows two helpful ideas:
- Name commands.
venvandinstallare easy to remember and easy to read later. - Build on changes. If
requirements.txthas not changed and the venv exists,installdoes nothing.
Naming commands is power
Good names are self documenting. Your future self will thank you when you type make format instead of hunting for a long uv run command.
Examples of helpful names:
format: run your formatter on all codelint: run static analysis checkstypecheck: run type checkertest: run testsquality: run all code quality tasks
Here is how that can look with uv and Python. Notice that we keep everything in the venv without sourcing it.
.PHONY: format lint typecheck test quality
PY_FILES := $(shell find . -type f -name "*.py" -not -path "$(VENV)/*")
format: $(VENV) ## Run Black formatter
$(UV) pip install -p $(VENV) black
$(VENV)/bin/black $(PY_FILES)
lint: $(VENV) ## Run Ruff linter
$(UV) pip install -p $(VENV) ruff
$(VENV)/bin/ruff check $(PY_FILES)
typecheck: $(VENV) ## Run pyright type checker
$(UV) pip install -p $(VENV) pyright
$(VENV)/bin/pyright
test: $(VENV) ## Run pytest test suite
$(UV) pip install -p $(VENV) pytest
$(VENV)/bin/pytest -q
# Aggregate target names the work you want
quality: lint format typecheck ## Run all code quality checks
quality is a named story about what happens. It is clear, easy to remember, and it shows what runs.
Building on changes with timestamps and stamp files
make is time based. A target rebuilds when a prerequisite is newer than the target. For phony tasks like formatting, you can create a stamp file to track when the work last completed.
.PHONY: format lint
LINT_STAMP := .lint.stamp
FORMAT_STAMP := .format.stamp
$(LINT_STAMP): $(PY_FILES) | $(VENV)
$(UV) pip install -p $(VENV) ruff
$(VENV)/bin/ruff check $(PY_FILES)
@touch $@
$(FORMAT_STAMP): $(PY_FILES) | $(VENV)
$(UV) pip install -p $(VENV) black
$(VENV)/bin/black $(PY_FILES)
@touch $@
lint: $(LINT_STAMP) ## Lint only if files changed
format: $(FORMAT_STAMP) ## Format only if files changed
If no Python files changed since the stamp files were created, make will skip the work.
Order only prerequisites | $(VENV) ensure the environment exists but do not force reruns when the venv timestamps change.
Functions, conditionals, and variables
Assignment operators
:=immediate assignment. Expands the right side now.=recursive assignment. Expands when used. The value can change if referenced variables change later.?=set if not already defined. Good for defaults that users can override with environment variables or command linemake VAR=value.
PROJECT_NAME ?= sample-project
SRC_DIR := src
PY_FILES = $(wildcard $(SRC_DIR)/**/*.py) # expands later
ABS_SRC := $(abspath $(SRC_DIR)) # expands now
Conditionals with if and $(if ...)
Make has a directive style ifeq and a function style $(if ...). Both are useful.
MODE ?= dev
ifeq ($(MODE),prod)
OPTS := --strict
else
OPTS := --fast
endif
run: $(VENV)
$(VENV)/bin/python -m app.main $(OPTS)
Function style works inline:
FLAGS := $(if $(filter $(MODE),prod),--strict,--fast)
Using functions like shell, wildcard, and subst
GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
SRC := $(wildcard src/**/*.py)
TESTS := $(patsubst tests/%.py,%,$(wildcard tests/test_*.py))
print-info:
@echo Project: $(PROJECT_NAME)
@echo SHA: $(GIT_SHA)
@echo Src files: $(words $(SRC))
Multi line variables with define
define PY_HEADER
#!/usr/bin/env python3
import sys
print("hello from a generated file")
endef
generated.py:
@printf "%s\n" "$(PY_HEADER)" > $@
Running shell commands and using $$ correctly
Inside recipes, the shell sees dollar signs. make also uses $ to expand its own variables. To pass a literal dollar sign to the shell, write $$.
show-path:
@echo Make variable PATH is: $(PATH)
@echo Shell expands PATH like this:
@echo $$PATH
count-py:
@echo Counting with the shell
@echo $$(find . -type f -name "*.py" | wc -l)
$(PATH) is a make variable expansion. $$PATH lets the recipe shell expand it at runtime. The same pattern works for $$?, $$@, and other shell variables.
You can also capture a shell result into a make variable outside recipes using $(shell ...) as shown earlier.
Automatic variables and file building
Automatic variables keep recipes short and readable.
DATA_DIR := data
BUILD_DIR := build
$(BUILD_DIR):
mkdir -p $@
# Pattern rule: transform .txt into .out using Python
$(BUILD_DIR)/%.out: $(DATA_DIR)/%.txt | $(BUILD_DIR) $(VENV)
@echo Converting $< to $@
$(VENV)/bin/python - <<'PY'
from pathlib import Path
inp = Path("$<").read_text()
Path("$@").write_text(inp.upper())
print("wrote", "$@")
PY
process: $(BUILD_DIR)/alpha.out $(BUILD_DIR)/beta.out ## Build processed files
$< is the first prerequisite. $@ is the target path. This creates a simple, reproducible text processing pipeline using Python.
One shell per recipe vs many shells
By default each recipe line runs in its own shell. That keeps each line independent. If you need one shared shell, you can opt in with .ONESHELL:.
.ONESHELL:
activate-and-run: $(VENV)
set -e
. $(VENV)/bin/activate
python -V
Most of the time you do not need activation at all because we call tools by absolute path or through uv. Prefer explicit paths. Use .ONESHELL only when it makes the recipe clearer.
Guard rails and phony targets
Declare phony targets so their existence does not depend on files with the same names.
.PHONY: clean distclean
clean: ## Remove build byproducts
rm -rf $(BUILD_DIR) $(LINT_STAMP) $(FORMAT_STAMP)
distclean: clean ## Remove environment as well
rm -rf $(VENV)
Use variable guards to catch misconfigurations early.
ifndef PROJECT_NAME
$(error PROJECT_NAME is not defined. Run with make PROJECT_NAME=myproj target)
endif
End to end example Makefile
Here is a cohesive Makefile that brings it all together. It uses uv and Python for every task, names commands clearly, rebuilds only when inputs change, and demonstrates variables, conditionals, functions, automatic variables, and escaping with $$.
.PHONY: help venv install lint format typecheck test quality build docs package process show-path clean distclean
# Configurable defaults
PROJECT_NAME ?= demo
UV ?= uv
VENV ?= .venv
REQS ?= requirements.txt
MODE ?= dev
# Derived variables
PYTHON := $(VENV)/bin/python
PY_FILES := $(shell find . -type f -name "*.py" -not -path "$(VENV)/*")
BUILD_DIR := build
DATA_DIR := data
ARTIFACT_DIR ?= dist
# Stamps for incremental quality tasks
LINT_STAMP := .lint.stamp
FORMAT_STAMP := .format.stamp
# Helpful banner and per target descriptions (##)
help:
@printf "\n$(PROJECT_NAME) commands\n\n"
@awk 'BEGIN{FS=":.*##"}; /^[a-zA-Z0-9_-]+:.*##/{printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# Environment
venv: | $(VENV) ## Create Python venv with uv
$(VENV):
$(UV) venv --seed --python=python3 $(VENV)
install: $(VENV) $(REQS) ## Install dependencies into venv with uv
$(UV) pip install -p $(VENV) -r $(REQS)
# Quality
$(LINT_STAMP): $(PY_FILES) | $(VENV)
$(UV) pip install -p $(VENV) ruff
$(VENV)/bin/ruff check $(PY_FILES)
@touch $@
$(FORMAT_STAMP): $(PY_FILES) | $(VENV)
$(UV) pip install -p $(VENV) black
$(VENV)/bin/black $(PY_FILES)
@touch $@
lint: $(LINT_STAMP) ## Lint only changed files since last run
format: $(FORMAT_STAMP) ## Format only changed files since last run
typecheck: $(VENV) ## Run pyright type checker
$(UV) pip install -p $(VENV) pyright
$(VENV)/bin/pyright
test: $(VENV) ## Run test suite with pytest
$(UV) pip install -p $(VENV) pytest
$(VENV)/bin/pytest -q
quality: lint format typecheck ## Run all quality checks
# Build and docs
build: $(VENV) ## Build the package artifacts
$(UV) pip install -p $(VENV) build
$(PYTHON) -m build -o $(ARTIFACT_DIR)
docs: $(VENV) ## Build docs with Sphinx
$(UV) pip install -p $(VENV) sphinx
$(VENV)/bin/sphinx-build -b html docs/ site/
package: build docs ## Aggregate build and docs
# Data processing pipeline using automatic vars and Python
$(BUILD_DIR):
mkdir -p $@
$(BUILD_DIR)/%.out: $(DATA_DIR)/%.txt | $(BUILD_DIR) $(VENV)
@echo Processing $< -> $@
$(PYTHON) - <<'PY'
from pathlib import Path
inp = Path("$<").read_text()
Path("$@").write_text(inp.upper())
print("done:", "$@")
PY
process: $(BUILD_DIR)/alpha.out $(BUILD_DIR)/beta.out ## Build processed data files
# Show make vs shell variable expansion and a shell command that uses $$
show-path: ## Demonstrate $$ escaping
@echo Make PATH is: $(PATH)
@echo Shell PATH is: $$PATH
@echo Count py files with the shell: $$(find . -type f -name "*.py" | wc -l)
# Conditional flags example
ifeq ($(MODE),prod)
FLAGS := --strict
else
FLAGS := --fast
endif
run: $(VENV) ## Run the app with mode dependent flags
$(PYTHON) -m app.main $(FLAGS)
# Clean
.PHONY: clean distclean
clean: ## Remove build byproducts
rm -rf $(BUILD_DIR) $(LINT_STAMP) $(FORMAT_STAMP)
distclean: clean ## Remove environment as well
rm -rf $(VENV)
You can run several things at once when they do not depend on each other:
make -j 4 quality processThis runs lint, format, and typecheck concurrently, and also builds your processed files, which can speed up feedback on larger projects.
Final notes
- Name commands generously. It is easier to understand
make qualitythan a long one off command. - Let
makedecide what to rebuild. Attach your targets to the files that matter and use stamp files for phony work. - Keep recipes stable by addressing tools through explicit paths or via uv. That avoids fragile activation steps.
- Prefer small targets that compose well. It keeps your graph clear and your builds fast.
With these patterns you get a Makefile that reads like documentation, runs quickly because it rebuilds only what changed, and uses uv and Python everywhere for a clean developer experience.