← Tech Guides
Section 01

Quick Reference

Targets, prerequisites, and recipes — the three wheels of every rule

Anatomy of a Rule

Every Makefile is built from rules. A rule tells Make how to produce a target from its prerequisites using a recipe.

# Basic rule structure
target: prerequisites
	recipe-command          # MUST be indented with a TAB, not spaces

# Example: compile main.o from main.c and config.h
main.o: main.c config.h
	gcc -c main.c -o main.o
Critical: Tabs, Not Spaces

Recipe lines must begin with a literal TAB character. Spaces will cause a *** missing separator error. Configure your editor to insert real tabs in Makefiles.

Core Concepts at a Glance

Concept Syntax Purpose
Target name: File to create or action to run
Prerequisite target: dep1 dep2 Files that must exist / be newer
Recipe command Shell commands to build the target
Default goal First target in file What make builds with no arguments
Comment # text Ignored by Make
Line continuation \ at end of line Split long lines

Multiple Targets & Rules

# Multiple targets sharing a recipe
foo.o bar.o: %.o: %.c
	$(CC) -c $< -o $@

# Multiple prerequisites on separate lines
app: main.o
app: utils.o
app: config.o
	$(CC) $^ -o $@

# Order-only prerequisites (right of |, never trigger rebuild)
output/report.pdf: report.tex | output
	pdflatex -output-directory=output report.tex

output:
	mkdir -p output

Running Make

Build default target

make

Runs the first target in the Makefile

Build specific target

make clean

Build only the named target

Parallel builds

make -j$(nproc)

Run recipes in parallel using all CPU cores

Override a variable

make CC=clang

Pass variable on the command line

Use alternate file

make -f build.mk

Read a Makefile with a non-default name

Keep going on error

make -k

Continue building other targets after a failure

Section 02

Variables

The jewel bearings and adjustment screws that calibrate your build

Assignment Operators

Operator Name Behavior
= Recursive Expanded each time the variable is used (lazy). References to other variables resolve at use time.
:= Simple Expanded once at definition time (eager). Faster, avoids infinite recursion.
?= Conditional Set only if the variable is not already defined. Great for defaults.
+= Append Append to existing value (space-separated). Preserves the original flavor (recursive or simple).
# Recursive: re-evaluated each time
CC = gcc
CFLAGS = -Wall $(EXTRA_FLAGS)     # EXTRA_FLAGS resolved at use, not here

# Simple: evaluated once at assignment
BUILDDIR := build
NOW := $(shell date +%Y%m%d)       # Captured once

# Conditional: set only if unset
PREFIX ?= /usr/local               # User can override with make PREFIX=/opt

# Append: add to existing value
CFLAGS += -O2
CFLAGS += -g

Automatic Variables

Make provides special variables inside recipes that refer to the current rule's target and prerequisites.

Variable Expands To Example
$@ The target filename app in app: main.o
$< First prerequisite main.c in main.o: main.c utils.h
$^ All prerequisites (deduplicated) main.o utils.o
$+ All prerequisites (with duplicates) Useful when link order matters
$* The stem of a pattern match main when %.o matches main.o
$(@D) Directory part of $@ build/ from build/main.o
$(@F) File part of $@ main.o from build/main.o
# Automatic variables in action
build/%.o: src/%.c
	@mkdir -p $(@D)                  # Create directory for target
	$(CC) $(CFLAGS) -c $< -o $@      # Compile first prereq to target

app: $(OBJECTS)
	$(CC) $^ -o $@                   # Link all prereqs into target

Environment & Override

# Environment variables are available as Make variables
# Command-line overrides take highest priority:
#   1. make CC=clang         (highest)
#   2. Makefile: CC := gcc
#   3. Environment: CC=gcc   (lowest, unless using -e flag)

# Export variables to sub-make and recipe shells
export GOPATH := $(HOME)/go

# Override directive: ignores command-line overrides
override CFLAGS += -Wall          # Always include -Wall
Section 03

Pattern Rules & Implicit Rules

Interchangeable wheels — one template drives many targets

Pattern Rules with %

A % in the target matches any non-empty string (the stem). The same stem is substituted into the prerequisites.

# Compile any .c file into a .o file
%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Generate HTML from Markdown
%.html: %.md
	pandoc $< -o $@

# With a directory prefix
build/%.o: src/%.c include/%.h
	@mkdir -p $(@D)
	$(CC) $(CFLAGS) -I include -c $< -o $@

Static Pattern Rules

Apply a pattern to an explicit list of targets, giving finer control than a plain pattern rule.

OBJECTS := main.o utils.o parser.o

# Static pattern: targets: target-pattern: prereq-pattern
$(OBJECTS): %.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

# Only these specific .o files use this rule,
# unlike a plain %.o rule which matches everything

Built-in Implicit Rules

GNU Make ships with dozens of implicit rules. You can rely on them or override them with your own.

From To Implicit Command
.c .o $(CC) $(CPPFLAGS) $(CFLAGS) -c
.cpp .o $(CXX) $(CPPFLAGS) $(CXXFLAGS) -c
.o executable $(CC) $(LDFLAGS) ... $(LDLIBS)
.y .c $(YACC) $(YFLAGS)
.l .c $(LEX) $(LFLAGS)
# Because of implicit rules, this is a valid Makefile!
# Make knows how to turn .c into executable
hello: hello.c

# Disable all implicit rules (faster, more explicit)
MAKEFLAGS += --no-builtin-rules
.SUFFIXES:

Canceling Implicit Rules

# Cancel a specific implicit rule by defining it with no recipe
%.o: %.c

# Cancel all suffix rules
.SUFFIXES:

# Then define only the suffixes you want
.SUFFIXES: .c .o
Section 04

Phony Targets

Commands that execute regardless of file state

What Is .PHONY?

A phony target does not correspond to an actual file. Without .PHONY, if a file named clean exists in the directory, make clean would say "already up to date" and do nothing.

# Declare phony targets so they always run
.PHONY: clean install test lint all

clean:
	rm -rf build/ *.o

install: build
	cp build/app /usr/local/bin/

test:
	./run_tests.sh

lint:
	shellcheck scripts/*.sh

Common Phony Targets

all

.PHONY: all

Build everything. Usually the default target.

clean

.PHONY: clean

Remove generated files and build artifacts.

install

.PHONY: install

Copy binaries to system directories.

test

.PHONY: test

Run the test suite.

dist

.PHONY: dist

Create a distribution tarball.

help

.PHONY: help

Print available targets and descriptions.

Self-Documenting Help Target

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

build: ## Build the application
	go build -o bin/app ./cmd/app

test: ## Run all tests
	go test ./...

clean: ## Remove build artifacts
	rm -rf bin/
Tip: Default to Help

Set .DEFAULT_GOAL := help so running make with no arguments displays available targets instead of building.

Phony as Grouping Mechanism

.PHONY: all build test deploy

# Phony targets as prerequisite groups
all: build test

build: bin/server bin/cli

test: unit-test integration-test

deploy: build test
	./scripts/deploy.sh production
Section 05

Functions

Precision instruments for text transformation and list manipulation

wildcard

Glob for files. Unlike shell globs, Make does not expand * in variable definitions — use $(wildcard) explicitly.

# Find all C source files
SRCS := $(wildcard src/*.c)
SRCS += $(wildcard src/**/*.c)

# Check if a file exists
ifneq ($(wildcard config.mk),)
    include config.mk
endif

patsubst

Pattern substitution: replace one pattern with another across a list of words.

SRCS    := main.c utils.c parser.c
OBJECTS := $(patsubst %.c, %.o, $(SRCS))
# Result: main.o utils.o parser.o

# Shorthand substitution reference (same result)
OBJECTS := $(SRCS:.c=.o)

# Change directory
BUILD_OBJECTS := $(patsubst src/%.c, build/%.o, $(SRCS))

foreach

Iterate over a list, producing a new list. Useful for generating per-directory or per-module rules.

MODULES := auth api db

# Generate a list of test commands
TEST_CMDS := $(foreach mod,$(MODULES),go test ./$(mod)/...)
# Result: go test ./auth/... go test ./api/... go test ./db/...

# Generate include paths
DIRS      := core utils network
INCLUDES  := $(foreach d,$(DIRS),-I$(d)/include)
# Result: -Icore/include -Iutils/include -Inetwork/include

shell

Execute a shell command and capture its stdout. Newlines are replaced with spaces.

GIT_SHA  := $(shell git rev-parse --short HEAD)
DATE     := $(shell date +%Y-%m-%d)
NPROC    := $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
GOFILES  := $(shell find . -name '*.go' -not -path './vendor/*')
Performance Warning

Use := (simple assignment) with $(shell) to avoid re-executing the command on every reference. With = (recursive), the shell command runs each time the variable is expanded.

Other Useful Functions

Function Syntax Result
filter $(filter %.c, $(FILES)) Keep only .c files from list
filter-out $(filter-out test%, $(SRCS)) Remove matching items
sort $(sort $(LIST)) Sort and deduplicate
word $(word 2, $(LIST)) Extract nth word (1-based)
words $(words $(LIST)) Count of words in list
strip $(strip $(VAR)) Remove leading/trailing whitespace
subst $(subst from,to,text) Literal string replacement
dir $(dir src/main.c) src/
notdir $(notdir src/main.c) main.c
basename $(basename main.c) main
addprefix $(addprefix build/, $(OBJ)) Prepend to each word
addsuffix $(addsuffix .o, $(MODS)) Append to each word
Section 06

Conditionals

Escapement levers that route the build based on configuration

ifdef / ifndef

Test whether a variable has been defined (even if empty for ifdef, or truly unset for ifndef).

# Set defaults only if not already defined
ifndef VERBOSE
    VERBOSE := 0
endif

ifdef DEBUG
    CFLAGS += -g -O0 -DDEBUG
else
    CFLAGS += -O2 -DNDEBUG
endif

# Require a variable to be set
ifndef DEPLOY_TARGET
    $(error DEPLOY_TARGET is not set. Use: make deploy DEPLOY_TARGET=prod)
endif

ifeq / ifneq

Compare two strings for equality or inequality.

# OS-specific settings
UNAME_S := $(shell uname -s)

ifeq ($(UNAME_S),Linux)
    LDFLAGS += -lpthread
    OPEN    := xdg-open
endif

ifeq ($(UNAME_S),Darwin)
    LDFLAGS += -framework CoreFoundation
    OPEN    := open
endif

# Check for a specific compiler
ifneq ($(CC),clang)
    CFLAGS += -Wno-unused-result
endif

# Check if a command exists
ifeq ($(shell command -v docker 2>/dev/null),)
    $(error docker is not installed)
endif

Conditional Functions

# $(if condition,then,else) — inline conditional
VERBOSE ?= 0
Q       := $(if $(filter 1,$(VERBOSE)),,@)

build:
	$(Q)echo "Building..."           # Silent unless VERBOSE=1
	$(Q)$(CC) $(CFLAGS) -o app main.c

# $(or val1,val2,...) — first non-empty value
CC := $(or $(CC),gcc)

# $(and val1,val2,...) — empty if any arg is empty
READY := $(and $(CC),$(SRCS),$(BUILDDIR))

$(error) and $(warning)

# Halt the build with an error message
ifndef API_KEY
    $(error API_KEY is required. Export it or pass via make API_KEY=xxx)
endif

# Print a warning but continue
ifeq ($(shell test -f .env && echo yes),)
    $(warning No .env file found, using defaults)
endif
Section 07

Recursive vs Non-Recursive Make

One grand complication, or many independent movements?

Recursive Make

Each subdirectory has its own Makefile. A top-level Makefile invokes make in each subdirectory using $(MAKE).

# Top-level Makefile (recursive style)
SUBDIRS := lib src tests

.PHONY: all clean $(SUBDIRS)

all: $(SUBDIRS)

$(SUBDIRS):
	$(MAKE) -C $@              # Invoke make in subdirectory

# Enforce build order: src depends on lib
src: lib

clean:
	for dir in $(SUBDIRS); do $(MAKE) -C $$dir clean; done
The Classic Paper

Peter Miller's "Recursive Make Considered Harmful" (1998) argues that recursive Make breaks dependency tracking across directories, leading to incorrect incremental builds and slower parallel execution.

Non-Recursive Make

A single top-level Makefile includes fragments from subdirectories. The entire dependency graph is visible to one Make process.

# Top-level Makefile (non-recursive style)
SRCS :=
OBJS :=

# Each module appends to shared variables
include lib/module.mk
include src/module.mk
include tests/module.mk

app: $(OBJS)
	$(CC) $^ -o $@
# src/module.mk
SRC_SRCS := $(wildcard src/*.c)
SRC_OBJS := $(SRC_SRCS:.c=.o)

SRCS += $(SRC_SRCS)
OBJS += $(SRC_OBJS)

# Local rules
src/%.o: src/%.c
	$(CC) $(CFLAGS) -c $< -o $@

Comparison

Aspect Recursive Non-Recursive
Cross-directory deps Broken / manual ordering Full graph visibility
Parallel builds Limited by recursion boundaries Fully parallel
Simplicity Each dir is self-contained Requires careful variable scoping
Scalability Easy to add new directories Better for large projects
Correctness Prone to stale builds Correct by construction
Practical Advice

For small to medium projects, recursive Make is fine and simpler to reason about. For large C/C++ codebases where build correctness and speed matter, prefer non-recursive Make or a build generator like CMake.

Section 08

Common Patterns

Proven calibrations for C, Go, Docker, and polyglot projects

C / C++ Project

CC       := gcc
CFLAGS   := -Wall -Wextra -std=c17
LDFLAGS  :=
LDLIBS   := -lm

SRCDIR   := src
BUILDDIR := build
TARGET   := $(BUILDDIR)/app

SRCS     := $(wildcard $(SRCDIR)/*.c)
OBJS     := $(patsubst $(SRCDIR)/%.c, $(BUILDDIR)/%.o, $(SRCS))
DEPS     := $(OBJS:.o=.d)

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@

$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR)
	$(CC) $(CFLAGS) -MMD -MP -c $< -o $@

$(BUILDDIR):
	mkdir -p $@

clean:
	rm -rf $(BUILDDIR)

# Auto-include generated dependency files
-include $(DEPS)
Automatic Dependencies

-MMD -MP tells GCC to generate .d files tracking header dependencies. The -include directive loads them silently. This ensures changing a header triggers recompilation of all affected sources.

Go Project

BINARY   := myapp
VERSION  := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT   := $(shell git rev-parse --short HEAD)
DATE     := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS  := -s -w \
    -X main.version=$(VERSION) \
    -X main.commit=$(COMMIT) \
    -X main.date=$(DATE)

.PHONY: all build test lint clean run

all: lint test build

build:
	CGO_ENABLED=0 go build -ldflags '$(LDFLAGS)' -o bin/$(BINARY) ./cmd/$(BINARY)

test:
	go test -race -coverprofile=coverage.out ./...

lint:
	golangci-lint run ./...

run: build
	./bin/$(BINARY)

clean:
	rm -rf bin/ coverage.out

# Cross-compile for Linux
build-linux:
	GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' -o bin/$(BINARY)-linux ./cmd/$(BINARY)

Docker Workflow

IMAGE    := myorg/myapp
TAG      := $(shell git describe --tags --always)
REGISTRY ?= ghcr.io

.PHONY: docker-build docker-push docker-run docker-clean

docker-build:
	docker build \
		--build-arg VERSION=$(TAG) \
		-t $(IMAGE):$(TAG) \
		-t $(IMAGE):latest \
		.

docker-push: docker-build
	docker tag $(IMAGE):$(TAG) $(REGISTRY)/$(IMAGE):$(TAG)
	docker push $(REGISTRY)/$(IMAGE):$(TAG)

docker-run:
	docker run --rm -p 8080:8080 $(IMAGE):latest

docker-clean:
	docker rmi $(IMAGE):$(TAG) $(IMAGE):latest 2>/dev/null || true

Polyglot Task Runner

Makefiles work well as a universal task runner, not just for compiled languages.

.PHONY: help install dev test deploy db-migrate db-seed

.DEFAULT_GOAL := help

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

install: ## Install all dependencies
	pip install -r requirements.txt
	npm ci
	pre-commit install

dev: ## Start development servers
	@echo "Starting backend..."
	python manage.py runserver &
	@echo "Starting frontend..."
	npm run dev

test: ## Run all tests
	pytest --cov=app tests/
	npm test

deploy: test ## Deploy to production
	./scripts/deploy.sh

db-migrate: ## Run database migrations
	python manage.py migrate

db-seed: ## Seed development database
	python manage.py loaddata fixtures/*.json
Section 09

Debugging

Diagnostic tools for when the mechanism stalls

Dry Run & Printing

make -n (dry run)

make -n build

Print commands without executing them. Shows what would happen.

make -B (force rebuild)

make -B all

Treat all targets as out of date. Rebuilds everything.

make -d (debug)

make -d target 2>&1 | less

Extremely verbose output showing all rule matching and decisions.

make --debug=v

make --debug=basic

Selective debug: basic, verbose, implicit, jobs, all.

make -p (database)

make -p -f /dev/null

Print the complete internal database of rules and variables.

make --trace

make --trace build

Print each target as it is remade, with prerequisite info. GNU Make 4.1+.

$(info), $(warning), $(error)

Inject diagnostic messages at parse time (during Makefile evaluation, not during recipe execution).

SRCS := $(wildcard src/*.c)

# Print at parse time
$(info [DEBUG] Sources found: $(SRCS))
$(info [DEBUG] Object files:  $(SRCS:.c=.o))

# Conditional warning
ifeq ($(SRCS),)
    $(warning No source files found in src/ — build will be empty)
endif

# Fatal error: stops the build immediately
ifndef CC
    $(error CC is not defined. Install a C compiler.)
endif

Printing Variables

# Debug target: print any variable
print-%:
	@echo '$*=$($*)'
	@echo '  origin = $(origin $*)'
	@echo '  flavor = $(flavor $*)'
	@echo '  value  = $(value $*)'

# Usage:
#   make print-CC
#   make print-CFLAGS
#   make print-SRCS
origin and flavor

$(origin VAR) tells you where a variable was defined: undefined, default, environment, file, command line, override, or automatic. $(flavor VAR) returns simple, recursive, or undefined.

Recipe Debugging

# By default, Make prints each command before running it.
# Prefixes modify this behavior:

build:
	@echo "Silent: @ suppresses command echo"
	-rm nonexistent.file   # - ignores errors (continues on failure)
	+$(MAKE) subtarget     # + runs even with make -n

# Verbose mode toggle
V ?= 0
ifeq ($(V),1)
    Q :=
else
    Q := @
endif

compile:
	$(Q)$(CC) $(CFLAGS) -c $< -o $@   # make V=1 to see commands

Common Errors & Fixes

Error Message Cause Fix
*** missing separator Spaces instead of tab in recipe Replace leading spaces with a real TAB
No rule to make target 'X' File does not exist and no rule produces it Check filename spelling, add rule, or fix path
Circular dependency dropped Target depends on itself Review dependency chain, break the cycle
*** No targets Makefile has no rules at all Ensure at least one target is defined
warning: overriding recipe Two rules for the same target with recipes Merge rules or use double-colon :: rules
Variable expansion is empty Recursive = with unset reference, or typo Use $(info) to inspect, check spelling
◦ ◦ ◦ ◦ ◦