Makefiles for Modern Projects: A Practical Tutorial
Use a Makefile as your project's command menu: targets, phony targets, variables, dependencies, a self-documenting help target and a real-world example.
· 6 min read
Contents
- Why use make in 2026?
- Your first Makefile
- Phony targets
- Variables
- Dependencies between targets
- Real file targets: only rebuild what changed
- A self-documenting help target
- A complete example for a full-stack project
- Common pitfalls
- Using make in CI
- Alternatives
- FAQ
- Do I need make for a Python or JavaScript project?
- What is the difference between = and :=?
- Can I pass arguments to a make target?
- Further reading
make was created in 1976 to compile C programs, and it is still one of the most useful tools in a modern project — not for compiling, but as a shared command menu. Instead of remembering docker compose -f compose.dev.yaml up --build or a five-flag test command, everyone on the team types make dev or make test.
This tutorial covers what you need to write clean, reliable Makefiles for Python, JavaScript or any other kind of project.
Why use make in 2026?
- It is already installed on Linux and macOS (install via Xcode command-line tools on macOS; on Windows use WSL, Git Bash with make, or a package manager such as Chocolatey or Scoop).
- It works with any language. Your Python API and your React frontend can share the same command interface.
- It documents your workflow. The Makefile is the list of things you can do in the project.
- CI and local development run the same commands, so "it passed locally" means something.
Your first Makefile
Create a file named Makefile (capital M, no extension):
hello:
echo "Hello from make"
Run it:
make hello
The structure is always:
target: prerequisites
recipe
The recipe line must start with a tab, not spaces. This is the most common Makefile error: missing separator. Configure your editor to keep real tabs in Makefiles.
By default make prints each command before running it. Prefix a line with @ to hide the command and show only its output:
hello:
@echo "Hello from make"
Phony targets
make was designed around files: a target is normally the name of a file to build, and make skips it if the file already exists and is up to date. If your project has a folder called test, then make test will say "test is up to date" and do nothing.
Declare command-style targets as phony so make always runs them:
.PHONY: test lint dev
test:
pytest -q
lint:
ruff check .
dev:
uvicorn main:app --reload
Variables
PYTHON ?= python3
PORT ?= 8000
SRC := src tests
lint:
$(PYTHON) -m ruff check $(SRC)
run:
$(PYTHON) -m uvicorn main:app --port $(PORT)
?=sets a default that can be overridden from the command line or the environment:make run PORT=9000.:=evaluates the value immediately (the usual choice).- Reference variables with
$(NAME).
To use a shell variable inside a recipe, double the dollar sign: $$HOME. A single $ is for make variables.
Dependencies between targets
Prerequisites run first, which lets you chain steps:
.PHONY: install lint test check
install:
pip install -r requirements.txt
lint:
ruff check .
test:
pytest -q
check: lint test
@echo "All checks passed"
make check runs lint, then test, then prints the message. If any step fails (non-zero exit code), make stops.
Real file targets: only rebuild what changed
make's original superpower still helps. This target reinstalls dependencies only when requirements.txt changes:
.venv/installed: requirements.txt
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
touch .venv/installed
.PHONY: test
test: .venv/installed
.venv/bin/pytest -q
The first make test creates the environment. Later runs skip straight to the tests — until you edit requirements.txt, which makes the marker file out of date.
A self-documenting help target
Add a comment with ## after each target and a help target that prints them:
.DEFAULT_GOAL := help
.PHONY: help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
.PHONY: dev
dev: ## Start the development server
docker compose up --build
.PHONY: test
test: ## Run the test suite
pytest -q
Now running plain make prints a neat list of commands with descriptions — new teammates discover the workflow without reading docs.
A complete example for a full-stack project
For a repository with a Python API in api/ and a Next.js frontend in web/:
.DEFAULT_GOAL := help
SHELL := /bin/bash
.PHONY: help install dev-api dev-web test lint build clean
help: ## Show available commands
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-10s %s\n", $$1, $$2}'
install: ## Install all dependencies
cd api && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cd web && npm ci
dev-api: ## Run the API with auto-reload
cd api && .venv/bin/uvicorn main:app --reload --port 8000
dev-web: ## Run the frontend dev server
cd web && npm run dev
test: ## Run all tests
cd api && .venv/bin/pytest -q
lint: ## Lint backend and frontend
cd api && .venv/bin/ruff check .
cd web && npm run lint
build: ## Production build of the frontend
cd web && npm run build
clean: ## Remove build artifacts
rm -rf web/.next api/.pytest_cache
Note that each recipe line runs in its own shell, so cd api on one line does not affect the next line. That is why each line starts with cd … &&.
Common pitfalls
| Problem | Cause | Fix |
|---|---|---|
missing separator. Stop. | Recipe indented with spaces | Use a tab character |
Nothing to be done for 'test' | A file or folder named test exists | Add the target to .PHONY |
cd seems ignored | Each line runs in a new shell | Use cd dir && command on one line |
$HOME is empty | make interpreted $H as a make variable | Write $$HOME |
| Works on Linux, fails on macOS | macOS ships an older GNU make (3.81) | Avoid newer features or install a recent make with Homebrew (gmake) |
Using make in CI
Because the Makefile is the single source of truth, your CI configuration stays short:
# .github/workflows/ci.yml (excerpt)
steps:
- uses: actions/checkout@v4
- run: make install
- run: make lint
- run: make test
Alternatives
If you dislike make's quirks, just is a popular command runner with similar syntax but no tab or file-target surprises, and npm scripts or Python task runners work well within a single ecosystem. make's advantage is that it is everywhere already.
FAQ
Do I need make for a Python or JavaScript project?
No, but it gives you one consistent interface across tools and languages, which becomes more valuable as a project grows.
What is the difference between = and :=?
= is evaluated every time the variable is used (lazily); := is evaluated once when defined. Prefer := unless you specifically need lazy evaluation.
Can I pass arguments to a make target?
Pass variables: make test ARGS="-k login", then use pytest $(ARGS) in the recipe.
Further reading
- GNU make manual
- Related: Bash scripting basics
- Related: How to write a good README
Keep reading
Bash Scripting Basics: A Practical Guide for Developers
Write reliable Bash scripts: shebang and strict mode, variables, quoting, conditionals, loops, functions, arguments and exit codes, with a complete example script.
Regex Cheat Sheet: Syntax and Real Examples You Can Copy
A practical regular expression cheat sheet: characters, quantifiers, groups, lookarounds and flags, plus tested patterns for emails, dates, logs and more.
How to Undo Almost Anything in Git (Without Losing Work)
Undo the last commit, unstage files, discard changes, fix a bad merge or recover deleted commits. Every Git undo scenario with the exact command to use.