Code of the Day
AdvancedAutomation

Makefiles

Use make as a language-agnostic task runner — targets, prerequisites, recipes, .PHONY, automatic variables ($@, $<, $^), and pattern rules.

BashAdvanced12 min read
Recommended first
By the end of this lesson you will be able to:
  • Write a Makefile with targets, prerequisites, and recipes
  • Mark targets as .PHONY to prevent conflicts with real files
  • Use automatic variables $@, $<, and $^ in recipes
  • Write pattern rules to handle classes of files
  • Use make as a task runner for non-C projects

Most people encounter make as a C build tool. But make is better understood as a dependency-aware task runner: it checks what needs to be done, runs only the necessary steps, and provides a standard interface for any project's common operations. The pattern is so useful that Python projects, Rust projects, and infrastructure-as-code repositories all use for their build, test, lint, and deploy targets — with no C in sight.

Basic structure

A Makefile consists of rules:

target: prerequisites
	recipe

The recipe is a shell command (or series of commands) that creates or updates the target. Recipes must be indented with a tab, not spaces — this is the most common Makefile error.

# Simplest Makefile
hello:
	echo "Hello, world"

clean:
	rm -f *.o output

Run a specific target with make target. Running make with no arguments runs the first target.

.PHONY: tasks, not files

By default, make checks whether a file named target exists. If it does, make thinks the target is up to date and skips it. Mark non-file targets as .PHONY to prevent this:

.PHONY: build test lint clean deploy

build:
	go build ./...

test:
	go test ./...

lint:
	golangci-lint run

clean:
	rm -rf dist/

deploy: build test
	./deploy.sh

The deploy: build test line means "run build and test first, then run deploy's recipe." If build fails, make stops before running test or deploy.

Makefile as a project's front door. In many open-source projects, the first thing a contributor does is run make or read the Makefile. A well-structured Makefile documents the available operations (make help is a common convention) and ensures every developer uses the same commands.

Variables

Makefile variables are set with = or :=:

# = is lazily evaluated (re-evaluated each use)
# := is eagerly evaluated (evaluated once at definition time)
VERSION := 1.2.3
BINARY  := myapp
OUTPUT  := dist/$(BINARY)-$(VERSION)

build:
	go build -o $(OUTPUT) .

.PHONY: build

Use $(VAR) to expand a variable (or ${VAR} — both work in make, unlike Bash).

Automatic variables

Inside a recipe, make provides several automatic variables that refer to the current rule's target and prerequisites:

VariableMeaning
$@The target name
$<The first prerequisite
$^All prerequisites (space-separated, deduplicated)
$*The stem matched by a % pattern rule
dist/myapp: main.go config.go utils.go
	go build -o $@ $^
#	         ^^  ^^
#	         |   all prerequisites
#	         the target (dist/myapp)

Pattern rules

A % wildcard creates a rule that applies to a class of targets. This is how C build systems compile every .c file to a .o file:

# Compile any .c file to a .o file
%.o: %.c
	gcc -c -o $@ $<

# Convert any Markdown file to HTML
docs/%.html: docs/%.md
	pandoc -o $@ $<

For a non-C example: a project that wants to minify every .js file in src/ to dist/:

SRCS := $(wildcard src/*.js)
OUTS := $(SRCS:src/%.js=dist/%.min.js)

all: $(OUTS)

dist/%.min.js: src/%.js
	terser $< -o $@

.PHONY: all

A practical task-runner Makefile

This pattern works for almost any project:

.DEFAULT_GOAL := help

.PHONY: help install test lint build clean

help:            ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
	  awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

install:         ## Install dependencies
	pip install -r requirements.txt

test:            ## Run the test suite
	pytest tests/

lint:            ## Run the linter
	ruff check .

build:           ## Build the package
	python -m build

clean:           ## Remove build artifacts
	rm -rf dist/ build/ *.egg-info __pycache__

The help target uses grep + awk to extract targets and their ## comment descriptions, printing a self-documenting command reference.

Tab vs space errors are cryptic. If you see Makefile:5: *** missing separator. Stop., a recipe line is indented with spaces instead of a tab. Most editors can be configured to use tabs for Makefiles specifically; many (VS Code, Vim) do this automatically.

Check your understanding

  1. 1.
    You have a target named "clean". There is also a file named "clean" in the directory. Running make clean does nothing. What fixes this?
  2. 2.
    In a recipe, $@ refers to which value?
  3. 3.
    Recipe lines in a Makefile can be indented with either tabs or spaces.

Do it yourself

# Create a minimal task-runner Makefile
mkdir -p /tmp/makefile-demo && cd /tmp/makefile-demo

cat > Makefile << 'EOF'
.PHONY: hello greet clean

hello:
	echo "Target: $@"

greet: hello
	echo "Hello from the greet target"
	echo "Prerequisite was: $<"

clean:
	echo "Nothing to clean in this demo"
EOF

make hello
make greet
make clean
make    # runs the first target (hello)

Where to go next

Makefiles give any project a consistent, discoverable interface. The next lesson — CI shell scripts — shows how to write shell scripts that work reliably in automated CI/CD environments: portable shebangs, strict mode, idempotent steps, and secret handling.

Finished reading? Mark it complete to track your progress.

On this page