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.