Make isn't just for C projects. It's a great task runner for Python too.

Why Make?

  • Universal: installed everywhere
  • Simple: just shell commands
  • Dependency-aware: only runs what's needed
  • Self-documenting: make help shows available targets

Basic Structure

.PHONY: help setup test lint clean
 
help:
	@echo "Available targets:"
	@echo "  setup  - Create venv and install deps"
	@echo "  test   - Run tests"
	@echo "  lint   - Run linter"
	@echo "  clean  - Remove artifacts"
 
setup:
	python -m venv .venv
	.venv/bin/pip install -e ".[dev]"
 
test:
	.venv/bin/pytest tests/ -v
 
lint:
	.venv/bin/ruff check src/ tests/
 
clean:
	rm -rf .venv __pycache__ .pytest_cache dist/

.PHONY tells Make these aren't real files.

Variables

VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
RUFF := $(VENV)/bin/ruff
 
test:
	$(PYTEST) tests/ -v
 
lint:
	$(RUFF) check src/

Change one variable, update everywhere.

Common Targets

.PHONY: setup dev test lint format check clean build publish
 
# Setup
setup: $(VENV)/bin/activate
 
$(VENV)/bin/activate:
	python -m venv $(VENV)
	$(PIP) install -e ".[dev]"
	touch $@
 
# Development
dev:
	$(PYTHON) -m myapp --debug
 
# Testing
test:
	$(PYTEST) tests/ -v
 
test-cov:
	$(PYTEST) tests/ --cov=src --cov-report=html
 
# Code quality
lint:
	$(RUFF) check src/ tests/
 
format:
	$(RUFF) format src/ tests/
 
typecheck:
	$(PYTHON) -m mypy src/
 
check: lint typecheck test
 
# Cleanup
clean:
	rm -rf $(VENV) dist/ build/ *.egg-info
	find . -type d -name __pycache__ -exec rm -rf {} +
	find . -type f -name "*.pyc" -delete
 
# Build & Publish
build:
	$(PYTHON) -m build
 
publish: build
	$(PYTHON) -m twine upload dist/*

Dependency Tracking

Make can track file dependencies:

# Only reinstall if pyproject.toml changes
$(VENV)/bin/activate: pyproject.toml
	python -m venv $(VENV)
	$(PIP) install -e ".[dev]"
	touch $@
 
# Only rebuild docs if source changes
docs/index.html: docs/*.md
	$(PYTHON) -m mkdocs build

Conditional Logic

# Detect OS
ifeq ($(OS),Windows_NT)
    PYTHON := $(VENV)/Scripts/python
else
    PYTHON := $(VENV)/bin/python
endif
 
# Use different settings for CI
ifdef CI
    PYTEST_ARGS := --no-header -q
else
    PYTEST_ARGS := -v
endif
 
test:
	$(PYTEST) tests/ $(PYTEST_ARGS)

Self-Documenting Help

.DEFAULT_GOAL := help
 
help:  ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'
 
setup: ## Create venv and install dependencies
	python -m venv .venv
	.venv/bin/pip install -e ".[dev]"
 
test: ## Run tests
	.venv/bin/pytest tests/ -v
 
lint: ## Run linter
	.venv/bin/ruff check src/
$ make
  help            Show this help
  setup           Create venv and install dependencies
  test            Run tests
  lint            Run linter

My Standard Makefile

.PHONY: help setup test lint format check clean
.DEFAULT_GOAL := help
 
VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
 
help:  ## Show available commands
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'
 
setup: $(VENV)/bin/activate  ## Create venv and install deps
 
$(VENV)/bin/activate: pyproject.toml
	python -m venv $(VENV)
	$(PIP) install --upgrade pip
	$(PIP) install -e ".[dev]"
	touch $@
 
test:  ## Run tests
	$(PYTHON) -m pytest tests/ -v
 
lint:  ## Run linter
	$(PYTHON) -m ruff check src/ tests/
 
format:  ## Format code
	$(PYTHON) -m ruff format src/ tests/
 
check: lint test  ## Run all checks
 
clean:  ## Remove build artifacts
	rm -rf $(VENV) dist/ build/ *.egg-info .pytest_cache .ruff_cache
	find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true

Tips

Always use .PHONY for targets that aren't files.

Use @ prefix to hide the command being run.

Use $(MAKE) for recursive Make calls.

Keep it simple. If a target gets complex, move it to a script.

Make is old but effective. Learn it once, use it everywhere.

React to this post: