Makefile: The Complete Guide for 2026

February 12, 2026

Make is one of the oldest and most reliable build automation tools in existence. Created by Stuart Feldman at Bell Labs in 1976, it has survived decades because its core concept is timelessly useful: define targets, declare their dependencies, and specify the commands to build them. If a dependency has not changed, Make skips the work. This dependency-driven model makes builds fast, reproducible, and easy to reason about.

Today, Make is far more than a C compiler driver. It serves as the universal task runner for Go, Python, Rust, JavaScript, and virtually any other language. It orchestrates Docker builds, Terraform deployments, and CI/CD pipelines. Every Linux and macOS machine ships with Make pre-installed, making it the lowest-friction way to give any project a standard interface: make build, make test, make deploy.

⚙ Related tools: Automate your builds on a schedule with the Crontab Generator, keep our Bash Cheat Sheet open for recipe commands, and validate your YAML configs with the YAML Validator.

Basic Makefile Syntax: Targets, Prerequisites, and Recipes

Every Makefile is built from rules. A rule tells Make how to produce a target file from its prerequisites (dependencies) by running a recipe (shell commands). The fundamental structure is:

target: prerequisites
	recipe-command-1
	recipe-command-2

Critical: Recipe lines must be indented with a real tab character, not spaces. This is the single most common Makefile error for beginners. Configure your editor to use hard tabs in Makefiles.

Here is a minimal but complete example:

# Build a C program from two source files
program: main.o utils.o
	gcc -o program main.o utils.o

main.o: main.c main.h
	gcc -c main.c

utils.o: utils.c utils.h
	gcc -c utils.c

clean:
	rm -f program *.o

When you run make with no arguments, Make builds the first target in the file (program). It checks whether the prerequisites (main.o, utils.o) exist and are up to date. If main.o is missing or older than main.c or main.h, Make rebuilds it first. This dependency chain is evaluated recursively, so Make always does the minimum amount of work needed.

Multiple Targets

A common pattern is an all target that builds everything:

.PHONY: all clean

all: server client tools

server: cmd/server/main.go
	go build -o bin/server ./cmd/server

client: cmd/client/main.go
	go build -o bin/client ./cmd/client

tools: cmd/tools/main.go
	go build -o bin/tools ./cmd/tools

clean:
	rm -rf bin/

Variables: Simple, Recursive, and Automatic

Variables eliminate repetition and make Makefiles configurable. There are three assignment operators with different evaluation semantics:

# Recursively expanded: re-evaluated on every use
CC = gcc
CFLAGS = -Wall -Wextra $(EXTRA_FLAGS)

# Simply expanded: evaluated once at assignment
BUILD_DIR := ./build
TIMESTAMP := $(shell date +%Y%m%d-%H%M%S)

# Conditional assignment: only if not already set
PREFIX ?= /usr/local

# Append to existing value
CFLAGS += -O2

The difference between = and := matters. With =, the right-hand side is stored as-is and expanded every time the variable is referenced. With :=, it is expanded immediately. Use := as your default to avoid surprising behavior, and reserve = for cases where you intentionally need deferred expansion.

Automatic Variables

Make provides automatic variables inside recipes that refer to parts of the current rule:

# $@ = target name
# $< = first prerequisite
# $^ = all prerequisites (deduplicated)
# $? = prerequisites newer than target
# $* = stem matched by a pattern rule

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@
# Expands to: gcc -Wall -Wextra -O2 -c main.c -o main.o

program: main.o utils.o lib.o
	$(CC) -o $@ $^
# Expands to: gcc -o program main.o utils.o lib.o

Automatic variables make pattern rules and generic recipes possible. Memorize $@, $<, and $^ — you will use them constantly.

Overriding Variables from the Command Line

Users can override any variable when invoking Make:

# In the Makefile
CC ?= gcc
CFLAGS ?= -O2

# From the command line
make CC=clang CFLAGS="-O3 -march=native"

Variables set on the command line take precedence over those in the Makefile (unless the Makefile uses override). The ?= operator is ideal for defaults that users should be able to change.

Pattern Rules and Wildcards

Pattern rules use the % wildcard to match filenames, letting you write a single rule that applies to many files:

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

# Convert any .md file into .html
%.html: %.md
	pandoc -s $< -o $@

# Generate .pb.go from .proto files
%.pb.go: %.proto
	protoc --go_out=. $<

The % character matches any non-empty string. In %.o: %.c, if Make needs to build parser.o, it matches % to parser, looks for parser.c as the prerequisite, and runs the recipe with $@=parser.o and $<=parser.c.

Finding Source Files with Wildcards

# Find all .c files in the current directory
SRCS := $(wildcard src/*.c)

# Transform .c filenames into .o filenames
OBJS := $(SRCS:.c=.o)
# Or equivalently:
OBJS := $(patsubst src/%.c, build/%.o, $(SRCS))

# Build all objects
build/%.o: src/%.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -c $< -o $@

program: $(OBJS)
	$(CC) -o $@ $^

Phony Targets (.PHONY)

A phony target is one that does not represent an actual file. Without the .PHONY declaration, Make checks whether a file with the target name exists. If a file called clean happens to exist, make clean would report "clean is up to date" and do nothing.

.PHONY: all build test lint clean install help

all: build test lint

build:
	go build -o bin/app ./cmd/app

test:
	go test ./...

lint:
	golangci-lint run

clean:
	rm -rf bin/ dist/ coverage/

install: build
	cp bin/app $(PREFIX)/bin/

help:
	@echo "Available targets:"
	@echo "  build   - Compile the application"
	@echo "  test    - Run all tests"
	@echo "  lint    - Run linter"
	@echo "  clean   - Remove build artifacts"
	@echo "  install - Install to $(PREFIX)/bin"

Declare every non-file target as .PHONY. This is not just defensive — it also signals intent. Anyone reading the Makefile can instantly see which targets produce files and which are task runners.

The Self-Documenting help Target

A popular pattern is a help target that extracts documentation from comments:

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

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

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

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

Running make help outputs a neatly formatted list of targets with descriptions. The ## comment convention is a community standard.

Conditional Directives

Make supports conditionals that control which parts of the Makefile are evaluated:

# Check if a variable equals a value
ifeq ($(OS),Windows_NT)
    BINARY := app.exe
    RM := del /Q
else
    BINARY := app
    RM := rm -f
endif

# Check if a variable is defined
ifdef VERBOSE
    Q :=
else
    Q := @
endif

build:
	$(Q)$(CC) $(CFLAGS) -o $(BINARY) main.c

# Check if a variable is empty
ifeq ($(strip $(DEBUG)),)
    CFLAGS += -O2
else
    CFLAGS += -g -O0 -DDEBUG
endif

The four conditional directives are ifeq, ifneq, ifdef, and ifndef. They are evaluated when the Makefile is parsed, not when recipes run. Use them for platform detection, feature flags, and build configuration.

Detecting the Environment

UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
    PLATFORM := linux
endif
ifeq ($(UNAME_S),Darwin)
    PLATFORM := darwin
endif

# Check if a command exists
HAS_DOCKER := $(shell command -v docker 2> /dev/null)
check-deps:
ifndef HAS_DOCKER
	$(error "docker is not installed")
endif

Built-in Functions

GNU Make provides functions for string manipulation, file operations, and shell integration:

String Functions

FILES := main.c utils.c parser.c

# Substitute text: $(subst from,to,text)
UPPER := $(subst .c,.C,$(FILES))
# Result: main.C utils.C parser.C

# Pattern substitute: $(patsubst pattern,replacement,text)
OBJS := $(patsubst %.c,%.o,$(FILES))
# Result: main.o utils.o parser.o

# Strip whitespace
CLEAN := $(strip   hello   world   )
# Result: "hello world"

# Find matching words: $(filter pattern,text)
CSRCS := $(filter %.c,$(ALL_FILES))
HEADERS := $(filter %.h,$(ALL_FILES))

# Find non-matching words: $(filter-out pattern,text)
NON_TESTS := $(filter-out %_test.go,$(GO_FILES))

# Word operations
FIRST := $(firstword $(FILES))     # main.c
COUNT := $(words $(FILES))          # 3

File Functions

# Find files matching a pattern (glob)
SRCS := $(wildcard src/**/*.c src/*.c)

# Extract directory part
DIRS := $(dir src/foo/bar.c src/baz.c)
# Result: src/foo/ src/

# Extract filename part
NAMES := $(notdir src/foo/bar.c src/baz.c)
# Result: bar.c baz.c

# Add prefix/suffix
LIBS := $(addprefix -l,pthread ssl crypto)
# Result: -lpthread -lssl -lcrypto

PATHS := $(addsuffix /main.go,cmd/server cmd/client)
# Result: cmd/server/main.go cmd/client/main.go

The shell Function

# Execute shell commands and capture output
GIT_HASH := $(shell git rev-parse --short HEAD)
GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
GO_VERSION := $(shell go version | awk '{print $$3}')
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
NUM_CPUS := $(shell nproc 2>/dev/null || sysctl -n hw.ncpu)

# Use in build flags
LDFLAGS := -X main.version=$(GIT_HASH) -X main.buildDate=$(DATE)

The foreach Function

# $(foreach var,list,text)
MODULES := auth api storage

# Create a target for each module
define MODULE_RULES
.PHONY: test-$(1)
test-$(1):
	go test ./$(1)/...
endef

$(foreach mod,$(MODULES),$(eval $(call MODULE_RULES,$(mod))))

# Now you can run: make test-auth, make test-api, make test-storage

Include Directives

Large projects split Makefiles into reusable fragments:

# Include another Makefile (error if missing)
include config.mk

# Include if it exists, ignore if missing
-include .env.mk
-include $(DEPS)

# Common pattern: auto-generated dependency files
SRCS := $(wildcard src/*.c)
DEPS := $(SRCS:.c=.d)

# Generate .d files with dependency info
%.d: %.c
	$(CC) -MM $< -MT $(@:.d=.o) -MF $@

-include $(DEPS)

The -include variant (with the dash) silently ignores missing files, which is essential for auto-generated dependency files that do not exist on the first build.

Recipe Prefixes and Shell Behavior

Each recipe line runs in its own shell. Make provides three line prefixes to control behavior:

target:
	@echo "Silent: @ suppresses command printing"
	-rm -f nonexistent_file   # Dash: ignore errors
	+$(MAKE) -C subdir        # Plus: run even with -n (dry run)

# Each line is a separate shell, so cd does not persist:
wrong:
	cd build
	./run-app          # This runs in the ORIGINAL directory!

# Fix: chain commands with && or use backslash continuation:
correct:
	cd build && ./run-app

also-correct:
	cd build && \
		./configure && \
		make && \
		make install

The @ prefix is especially common in phony targets where seeing the echo command before its output would be redundant. The - prefix is useful for cleanup targets where some files may not exist.

Using .ONESHELL

# Run the entire recipe in a single shell invocation
.ONESHELL:

deploy:
	set -euo pipefail
	cd $(BUILD_DIR)
	./configure --prefix=$(PREFIX)
	make -j$(NUM_CPUS)
	make install

Multi-Directory Projects

For projects with source code in multiple directories, there are two main approaches:

Recursive Make (traditional)

# Top-level Makefile
SUBDIRS := lib src tools

.PHONY: all clean $(SUBDIRS)

all: $(SUBDIRS)

$(SUBDIRS):
	$(MAKE) -C $@

# Declare inter-directory dependencies
src: lib
tools: lib src

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

The $(MAKE) -C dir pattern invokes Make in a subdirectory. Note the $$dir in the shell loop — the double dollar sign escapes the $ from Make's variable expansion so it reaches the shell as $dir.

Non-Recursive Make (modern)

# Single Makefile that includes per-directory fragments
BUILD := build

SRCS :=
include lib/module.mk
include src/module.mk
include tools/module.mk

OBJS := $(patsubst %.c,$(BUILD)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)

$(BUILD)/%.o: %.c
	@mkdir -p $(dir $@)
	$(CC) $(CFLAGS) -MMD -c $< -o $@

-include $(DEPS)

Each module.mk adds its source files: SRCS += lib/crypto.c lib/hash.c. The non-recursive approach gives Make full visibility into the dependency graph, enabling better parallelism with make -j.

Best Practices and Common Patterns

Use .DELETE_ON_ERROR

# Delete target file if the recipe fails (prevents corrupt files)
.DELETE_ON_ERROR:

output.json: input.csv
	python process.py $< > $@

Without this, a failed recipe could leave a partial output file that Make would consider up-to-date on the next run.

Parallel Builds

# Run up to 8 jobs in parallel
make -j8

# Use all available cores
make -j$(nproc)

# In the Makefile, set a default (GNU Make 4.x)
MAKEFLAGS += -j$(shell nproc 2>/dev/null || echo 4)

Quiet vs Verbose Modes

V ?= 0
ifeq ($(V),0)
    Q := @
else
    Q :=
endif

%.o: %.c
	$(Q)$(CC) $(CFLAGS) -c $< -o $@
# make V=0 (default): silent. make V=1: full commands.

Order-Only Prerequisites for Directories

# The pipe symbol means: build if missing, but do not check timestamp
$(BUILD_DIR)/%.o: %.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -c $< -o $@

$(BUILD_DIR):
	mkdir -p $@

This prevents rebuilding all objects just because the directory timestamp changed.

Real-World Makefile Examples

C/C++ Project

CC      := gcc
CFLAGS  := -Wall -Wextra -std=c11
LDFLAGS := -lm

SRC_DIR := src
BUILD_DIR := build
BIN := $(BUILD_DIR)/app

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

.PHONY: all clean
.DELETE_ON_ERROR:

all: $(BIN)

$(BIN): $(OBJS) | $(BUILD_DIR)
	$(CC) $(LDFLAGS) -o $@ $^

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

$(BUILD_DIR):
	mkdir -p $@

clean:
	rm -rf $(BUILD_DIR)

-include $(DEPS)

Go Project

APP_NAME := myservice
VERSION  := $(shell git describe --tags --always --dirty)
LDFLAGS  := -s -w -X main.version=$(VERSION)

.PHONY: all build test lint run clean docker

all: lint test build

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

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

lint:
	golangci-lint run ./...

run: build
	./bin/$(APP_NAME)

clean:
	rm -rf bin/ coverage.out

docker:
	docker build -t $(APP_NAME):$(VERSION) .

Python Project

PYTHON := python3
VENV   := .venv
PIP    := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
RUFF   := $(VENV)/bin/ruff

.PHONY: all venv install test lint format clean

all: install lint test

venv: $(VENV)/bin/activate

$(VENV)/bin/activate:
	$(PYTHON) -m venv $(VENV)
	$(PIP) install --upgrade pip

install: venv
	$(PIP) install -r requirements.txt
	$(PIP) install -r requirements-dev.txt

test: install
	$(PYTEST) tests/ -v --cov=src --cov-report=html

lint: install
	$(RUFF) check src/ tests/

format: install
	$(RUFF) format src/ tests/

clean:
	rm -rf $(VENV) __pycache__ .pytest_cache htmlcov
	find . -name "*.pyc" -delete

Docker and Docker Compose

IMAGE_NAME := myapp
IMAGE_TAG  := $(shell git rev-parse --short HEAD)
REGISTRY   := ghcr.io/myorg

.PHONY: build push run stop logs clean

build:
	docker build -t $(IMAGE_NAME):$(IMAGE_TAG) \
		-t $(IMAGE_NAME):latest .

push: build
	docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
	docker tag $(IMAGE_NAME):latest $(REGISTRY)/$(IMAGE_NAME):latest
	docker push $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)
	docker push $(REGISTRY)/$(IMAGE_NAME):latest

run:
	docker compose up -d

stop:
	docker compose down

logs:
	docker compose logs -f --tail=100

clean: stop
	docker compose down -v --rmi local
	docker system prune -f

Advanced Techniques

Canned Recipes with define

# Define a reusable recipe block
define build-go-binary
	CGO_ENABLED=0 go build \
		-ldflags "-s -w -X main.version=$(VERSION)" \
		-o bin/$(1) ./cmd/$(1)
endef

.PHONY: server worker cli

server:
	$(call build-go-binary,server)

worker:
	$(call build-go-binary,worker)

cli:
	$(call build-go-binary,cli)

Target-Specific Variables

# Set variables for a specific target only
debug: CFLAGS += -g -O0 -DDEBUG
debug: build

release: CFLAGS += -O3 -DNDEBUG
release: build

# These do not affect other targets

Prevent Tab Errors with .editorconfig

# .editorconfig
[Makefile]
indent_style = tab
indent_size = 4

[*.mk]
indent_style = tab
indent_size = 4

Frequently Asked Questions

What is the difference between = and := in a Makefile?

The = operator creates a recursively expanded variable, meaning its value is re-evaluated every time the variable is referenced. The := operator creates a simply expanded variable, meaning its value is evaluated once at the time of assignment. Use := when you want predictable, immediate expansion (which is the common case), and = when you intentionally need deferred evaluation. For example, with CC = $(COMPILER) the value changes if COMPILER is redefined later, while CC := $(COMPILER) locks in the value at assignment time. A third form, ?=, only assigns the value if the variable is not already defined, which is useful for providing defaults that users can override from the command line.

What does .PHONY mean in a Makefile?

The .PHONY directive tells Make that a target name does not represent an actual file. Without .PHONY, if a file named "clean" existed in your directory, running make clean would see that the file already exists and skip the recipe entirely. Declaring .PHONY: clean ensures the recipe always runs regardless of whether a file with that name exists. Common phony targets include clean, all, install, test, lint, build, and help. It is a best practice to declare all non-file targets as phony to avoid unexpected behavior and to signal intent clearly to anyone reading the Makefile.

How do I use automatic variables like $@, $<, and $^ in Make?

$@ is the target name of the rule being executed. $< is the first prerequisite (dependency). $^ is the list of all prerequisites with duplicates removed. $? is the list of prerequisites that are newer than the target. These are used inside recipes to avoid hardcoding filenames. For example, in a rule like program: main.o utils.o, the recipe $(CC) -o $@ $^ expands to gcc -o program main.o utils.o. Using automatic variables makes rules generic and reusable, especially in pattern rules like %.o: %.c where $< and $@ refer to the matched source and object files.

Why does Make complain about missing separator or tab errors?

Make requires that recipe lines (the commands under a target) are indented with a real tab character, not spaces. This is the most common Makefile syntax error. If your editor is configured to insert spaces when you press Tab, the Makefile will fail with "missing separator" errors. To fix this, configure your editor to use hard tabs in Makefiles. In VS Code, add a files.insertSpaces setting for Makefile file types. In Vim, use autocmd FileType make setlocal noexpandtab. The .editorconfig standard also supports per-filetype indent settings, which helps keep this consistent across teams.

Can I use Make for projects that are not C or C++?

Absolutely. While Make originated as a C build tool, it is a general-purpose task runner that works with any language or workflow. It is widely used in Go projects for build, test, and lint targets; in Python projects for managing virtual environments and running tests; in Docker workflows for building and pushing images; in Terraform and Kubernetes deployments; and for documentation generation. Any workflow that involves running shell commands in a defined order with dependencies between steps is a good fit for Make. Its ubiquity on Unix systems (it is pre-installed on virtually every Linux and macOS machine) makes it an excellent choice for a project's standard task interface regardless of the primary language.

Conclusion

Make has endured for nearly fifty years because its core model — targets, dependencies, and recipes — maps perfectly onto how builds and automation actually work. It does not require a runtime, a package manager, or a configuration language. It is a single binary that is already installed on your machine, and it does exactly what you tell it to do, no more and no less.

For new projects, start simple: a .PHONY section, a build target, a test target, a clean target, and a help target. That is enough to give your project a standard interface that every developer will understand. As the project grows, add variables for configuration, pattern rules for repetitive compilation steps, and conditionals for cross-platform support.

The Makefiles that work best in production are the ones that are readable, well-commented, and do not try to be clever. Let Make handle dependency tracking and incremental builds. Let shell commands do the actual work. Keep each target focused on a single responsibility. Your team will thank you when make deploy works reliably at 2 AM during an incident.

Continue Learning

Related Tools

Crontab Generator
Schedule automated Make targets with cron
YAML Validator
Validate CI/CD configs that run your Make targets
Bash Scripting Guide
Master the shell commands used in Makefile recipes
Docker Compose Guide
Container orchestration often paired with Make
GitHub Actions CI/CD
Run Make targets in automated CI/CD pipelines
Bash Cheat Sheet
Quick reference for shell commands and syntax