Advanced Makefile Patterns for Python Projects
I wrote previously about why Makefiles are useful for Python projects. That post covered the basics: what Make is, why you’d use it, and some simple examples. But I’ve been using Make for long enough now that I’ve accumulated a collection of patterns that go well beyond make test and make lint. Some of these are clever. Some are arguably too clever. All of them have saved me time.
Self-Documenting Makefiles
This is my favorite trick and I put it in every project now. Add this to your Makefile:
.DEFAULT_GOAL := help
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Now when someone clones your repo and types make with no arguments, they get a nicely formatted list of all available targets with descriptions:
build Build the package
clean Remove build artifacts
lint Run linter
serve Start development server
test Run test suite
The trick is the ## comment syntax. Any target with a ## description comment gets included in the help output. Targets without that comment are hidden. This means you can have internal helper targets that don’t clutter the help output.
Environment Detection
For projects that might run on different operating systems or in different contexts (local dev vs. CI), environment detection is useful:
UNAME := $(shell uname)
IS_CI := $(if $(CI),true,false)
ifeq ($(UNAME), Darwin)
OPEN_CMD := open
else
OPEN_CMD := xdg-open
endif
ifeq ($(IS_CI), true)
PYTEST_FLAGS := --no-header -q
else
PYTEST_FLAGS := -v --tb=short
endif
test: ## Run tests
uv run pytest $(PYTEST_FLAGS)
open-coverage: test ## Open coverage report in browser
$(OPEN_CMD) htmlcov/index.html
This way make test produces verbose output locally (where you want to see details) and quiet output in CI (where you want to see failures fast). And make open-coverage works on both macOS and Linux without anyone having to remember which command opens a browser.
Confirming Dangerous Actions
Some Makefile targets do things you don’t want to do accidentally. Deploying to production, dropping a database, publishing a package. A simple confirmation prompt prevents expensive mistakes:
confirm:
@echo -n "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ]
deploy: confirm ## Deploy to production
@echo "Deploying..."
gh workflow run deploy.yml
publish: confirm ## Publish package to PyPI
uv build
uv publish
Now make deploy asks for confirmation before doing anything. It’s a small thing, but it’s saved me from at least two accidental deployments triggered by muscle memory.
Checking Prerequisites
Nothing is more frustrating than running a Makefile target and having it fail three minutes in because some tool isn’t installed. Check for prerequisites upfront:
REQUIRED_BINS := uv hugo ruff
$(foreach bin,$(REQUIRED_BINS),\
$(if $(shell command -v $(bin) 2>/dev/null),,\
$(error "$(bin) is required but not installed")))
Put this near the top of your Makefile. If any required tool is missing, you get a clear error message immediately instead of a cryptic failure later.
Timestamped Targets
Make’s original purpose was avoiding unnecessary rebuilds by checking file timestamps. You can use this in Python projects too:
.stamps:
mkdir -p .stamps
.stamps/installed: pyproject.toml uv.lock | .stamps
uv sync
@touch $@
.stamps/linted: .stamps/installed $(shell find src -name "*.py")
uv run ruff check .
@touch $@
test: .stamps/linted ## Run tests (re-lints if needed)
uv run pytest
This creates stamp files that track when things last ran. uv sync only runs when pyproject.toml or uv.lock changes. Linting only runs when Python files change. Tests always run (because you usually want fresh test results), but they automatically lint first and only re-lint if source files have changed.
For large projects where linting takes 10+ seconds, this adds up fast.
Multi-Stage Builds
For projects that have multiple build stages, Make’s dependency system keeps things in order:
SOURCE_FILES := $(shell find src -name "*.py")
STATIC_FILES := $(shell find static -name "*")
.stamps/built: $(SOURCE_FILES) $(STATIC_FILES)
uv run python -m build
@touch $@
.stamps/tested: .stamps/built
uv run pytest
@touch $@
.stamps/docs: $(SOURCE_FILES)
uv run sphinx-build -b html docs/source docs/build
@touch $@
all: .stamps/tested .stamps/docs ## Build, test, and generate docs
make all builds everything in the right order and skips steps that don’t need to re-run. This is exactly what Make was designed for, and it works just as well for Python as it does for C.
Parameterized Targets
Sometimes you need the same target with different configurations:
# Usage: make setup-post series=parga post_number=3 post_name=island-models
setup-post: ## Create a new blog post with boilerplate
@test -n "$(series)" || (echo "series is required" && exit 1)
@test -n "$(post_number)" || (echo "post_number is required" && exit 1)
@test -n "$(post_name)" || (echo "post_name is required" && exit 1)
uv run python scripts/setup_post.py \
--series "$(series)" \
--number "$(post_number)" \
--name "$(post_name)"
The parameter validation at the top gives clear error messages instead of letting the command fail with confusing errors downstream. I use this exact pattern in this blog’s Makefile for setting up new posts.
The .PHONY Problem
If you’ve used Make, you’ve probably seen .PHONY scattered everywhere. Here’s a cleaner approach:
.PHONY: $(shell grep -E '^[a-zA-Z_-]+:' $(MAKEFILE_LIST) | \
grep -v '\.stamps' | sed 's/:.*//')
This automatically marks every target as phony except the stamped targets. No more manually maintaining a .PHONY list that’s always out of date.
When Make Isn’t Enough
I’ll be honest: there are situations where Make isn’t the right tool. If your build process involves complex conditional logic that changes based on runtime state, Make’s syntax gets painful fast. If you need to do significant string manipulation or data processing as part of your build, you’ll wish you were writing Python.
For those cases, I’ve seen teams use a hybrid approach: a Makefile as the entry point with a Python script doing the heavy lifting behind the scenes. make deploy calls a Python deployment script. make setup calls a Python setup wizard. You get the simple interface of Make with the full power of Python when you need it.
The Makefile in this blog’s repository is a good example. The simple targets (serve, build, clean) are pure Make. The more complex ones (setup-post, analyze-tags) delegate to Python scripts. Best of both worlds.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.