Makefiles for C++: Automatic Variables, Pattern Rules, -MMD Dependencies and Parallel Builds

Key takeaways

Makefile guide for C++ projects: tabs, automatic variables, wildcards, -MMD dependencies, parallel -j, and when to prefer CMake for cross-platform builds.

Introduction

A Makefile is the input file for make, a tool that rebuilds files from other files. Its whole idea fits in one sentence: for each target, you list what it depends on and the command that produces it, and Make reruns a command only when a dependency’s modification time is newer than the target’s. For C++, that means recompiling only the .cpp files that changed and relinking at the end, instead of compiling everything on every build.

Make does not know anything about C++. It does not parse #include lines, it does not know which flags your compiler accepts, and it will not notice that a header changed unless you tell it. Most Makefile bugs come from that gap between what Make actually tracks (timestamps of the files you listed) and what you assume it tracks. This article uses GNU Make syntax, which is what make is on Linux and what MSYS2 or Homebrew install elsewhere; BSD make differs in functions and conditionals.


Makefile basics

The simplest Makefile

# Makefile
myapp: main.cpp
	g++ main.cpp -o myapp
clean:
	rm -f myapp
# build
make
# organize
make clean

Output:

g++ main.cpp -o myapp

Running make without arguments builds the first target in the file, here myapp. Make echoes each command before running it; running make a second time without changing main.cpp prints make: 'myapp' is up to date. because the binary is newer than its only prerequisite. That timestamp comparison is the entire mechanism, which also explains a common surprise: touching a file (touch main.cpp) triggers a rebuild even if its contents did not change, while restoring an older file from backup may not.

Basic grammar

# Target: Dependency
# Command (be sure to start with a tab!)
target: dependencies
	command
# example
main.o: main.cpp
	g++ -c main.cpp -o main.o

component:

  • target: Name of the file or task to be created
  • Dependencies: Files needed to create the target
  • command: Shell command to execute (must start with tab)

Each recipe line runs in its own shell. cd build on one line followed by cmake .. on the next does not work, because the second line starts again in the original directory; write cd build && cmake .. on one line. Make also stops at the first command that returns a non-zero exit status, which is why a failed compile halts the build instead of linking stale objects.


Using variables

Basic variables

# variable definition
CXX = g++
CXXFLAGS = -std=c++17 -Wall -O2
INCLUDES = -I./include
LIBS = -lpthread -lm
# Use variables
myapp: main.cpp
	$(CXX) $(CXXFLAGS) $(INCLUDES) main.cpp -o myapp $(LIBS)
clean:
	rm -f myapp
.PHONY: clean

CXX, CXXFLAGS and LDLIBS are conventional names that Make’s built-in rules already use, so sticking to them lets users override flags from the command line (make CXXFLAGS="-O0 -g") without editing the file. Library flags such as -lpthread belong after the sources or objects on the link line: GNU ld resolves symbols left to right, and a library listed before the objects that need it is skipped, which shows up as undefined reference to 'pthread_create'.

There are two assignment operators worth distinguishing. = creates a recursively expanded variable, evaluated every time it is used, so it can refer to variables defined later. := expands once, at the point of definition. Use := for anything that calls $(shell ...) or $(wildcard ...), otherwise the command reruns every time the variable is referenced.

Automatic variables

CXX = g++
CXXFLAGS = -std=c++17 -Wall
# automatic variable
# $@: target name
# $<: first dependency
# $^: all dependencies
myapp: main.o util.o
	$(CXX) $^ -o $@
	# $^ = main.o util.o
	# $@ = myapp
%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
# $< = main.cpp (first dependency)
# $@ = main.o (target)

The comment lines inside the myapp recipe start with a tab, so Make passes them to the shell, which ignores them. Put comments at column 0 if you do not want them echoed during the build.

Automatic variable summary

variablemeaningExample
$@target namemyapp
$<first dependencymain.cpp
$^All dependenciesmain.o util.o
$?Newer dependency than targetmain.o

Practical example

Example 1: Simple project

# Compiler settings
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
# target
TARGET = myapp
# build
$(TARGET): main.cpp
	$(CXX) $(CXXFLAGS) main.cpp -o $(TARGET)
# execution
run: $(TARGET)
	./$(TARGET)
# organize
clean:
	rm -f $(TARGET)
# non-file target
.PHONY: clean run

How to use:

make # build
make run # run after build
make clean # cleanup

The run target depends on $(TARGET), so make run rebuilds first if needed and then runs the program. That is the typical use of Make beyond compiling: small task shortcuts that always operate on an up-to-date binary.

Example 2: Multiple file project

CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
TARGET = myapp
# object file
OBJS = main.o calculator.o utils.o
# linking
$(TARGET): $(OBJS)
	$(CXX) $(OBJS) -o $(TARGET)
# compile
main.o: main.cpp calculator.h utils.h
	$(CXX) $(CXXFLAGS) -c main.cpp
calculator.o: calculator.cpp calculator.h
	$(CXX) $(CXXFLAGS) -c calculator.cpp
utils.o: utils.cpp utils.h
	$(CXX) $(CXXFLAGS) -c utils.cpp
# organize
clean:
	rm -f $(OBJS) $(TARGET)
.PHONY: clean

This is the first Makefile that shows why Make exists. Change utils.cpp, and only utils.o is recompiled before relinking; the other objects are reused. The explicit header lists (main.o: main.cpp calculator.h utils.h) are what make header edits trigger recompilation, and they are also the weak point: add #include "config.h" to utils.cpp and forget to update the rule, and edits to config.h silently stop triggering rebuilds. The dependency-generation section below replaces these hand-written lists.

Example 3: Pattern Rules (Automation)

CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
TARGET = myapp
# Find all .cpp files
SRCS = $(wildcard *.cpp)
OBJS = $(SRCS:.cpp=.o)
# linking
$(TARGET): $(OBJS)
	$(CXX) $^ -o $@
# Pattern rule: all .cpp → .o
%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
	rm -f $(OBJS) $(TARGET)
.PHONY: clean

Pattern Rule Description:

  • %.o: %.cpp: Compile all .cpp files as .o
  • $<: first dependency (.cpp file)
  • $@: target (.o file)

$(wildcard *.cpp) is evaluated when the Makefile is read, so new source files are picked up automatically, which is convenient and occasionally surprising: a scratch file like test_old.cpp left in the directory is compiled and linked into the program, and if it defines its own main, the link fails with multiple definition of 'main'. Some teams list sources explicitly for exactly this reason.

Example 4: Library linking

CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
INCLUDES = -I./include
LDFLAGS = -lpthread -lm
TARGET = myapp
SRCS = $(wildcard src/*.cpp)
OBJS = $(SRCS:src/%.cpp=obj/%.o)
# linking
$(TARGET): $(OBJS)
	$(CXX) $^ -o $@ $(LDFLAGS)
# compile
obj/%.o: src/%.cpp
	@mkdir -p obj
	$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
clean:
	rm -rf obj $(TARGET)
.PHONY: clean

Keeping objects in obj/ separates build output from sources, so clean is a single rm -rf obj. The @mkdir -p obj line runs for every object; it is harmless but noisy in parallel builds. A tidier alternative is an order-only prerequisite, obj/%.o: src/%.cpp | obj, with a separate obj: rule that creates the directory; the part after | must exist but its timestamp never triggers a rebuild. Strictly speaking, -lpthread -lm are libraries, so the conventional variable for them is LDLIBS, while LDFLAGS holds options like -L/opt/lib.


Advanced features

Conditional compilation

CXX = g++
TARGET = myapp
# Change flags according to DEBUG variable
ifeq ($(DEBUG),1)
    CXXFLAGS = -std=c++17 -Wall -g -DDEBUG
else
    CXXFLAGS = -std=c++17 -Wall -O2 -DNDEBUG
endif
$(TARGET): main.cpp
	$(CXX) $(CXXFLAGS) main.cpp -o $(TARGET)
clean:
	rm -f $(TARGET)
.PHONY: clean

How to use:

make # Release build
make DEBUG=1 # Debug build

One trap here: Make does not know that the flags changed. After a release build, make DEBUG=1 reports that myapp is up to date, because no file is newer than the binary. Run make clean when switching configurations, or use separate output directories per configuration (obj/debug, obj/release) so each keeps its own objects. I have lost more time than I would like to a “debug” binary that was actually the optimized one, with breakpoints that refused to bind.

Using functions

CXX = g++
CXXFLAGS = -std=c++17 -Wall
# wildcard: file pattern matching
SRCS = $(wildcard src/*.cpp)
# patsubst: pattern substitution
OBJS = $(patsubst src/%.cpp,obj/%.o,$(SRCS))
# shell: Execute shell command
$(shell mkdir -p obj)
myapp: $(OBJS)
	$(CXX) $^ -o $@
obj/%.o: src/%.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
	rm -rf obj myapp
.PHONY: clean

Automatic creation of dependencies

CXX = g++
CXXFLAGS = -std=c++17 -Wall
SRCS = $(wildcard *.cpp)
OBJS = $(SRCS:.cpp=.o)
DEPS = $(OBJS:.o=.d)
myapp: $(OBJS)
	$(CXX) $^ -o $@
# -MMD: Create dependency files
%.o: %.cpp
	$(CXX) $(CXXFLAGS) -MMD -c $< -o $@
# Include dependency files
-include $(DEPS)
clean:
	rm -f $(OBJS) $(DEPS) myapp
.PHONY: clean

explanation:

  • -MMD: Create header dependencies as .d files.
  • -include: Include dependency files (no error occurs even without them)
  • Automatically recompiles when header file changes

With -MMD, each compile writes a small file such as main.d containing a rule like main.o: main.cpp calculator.h utils.h, listing every non-system header the file really included. On the next run, -include loads those rules, so the header lists are always correct without you maintaining them. The first build has no .d files yet, which is fine because everything is compiled anyway. Add -MP as well: it writes an empty rule for each header, so deleting or renaming a header produces a rebuild instead of No rule to make target 'old.h', needed by 'main.o'.


Frequently occurring problems

Issue 1: Tabs vs Spaces

# ❌ Use of space (error!)
target:
    command
# ✅ Use tabs
target:
	command

Error Message:

Makefile:2: *** missing separator. Stop.

Solution:

  • Disable converting tabs to spaces in the editor settings.
  • Use Makefile mode (automatically insert tabs)

The tab usually disappears when a Makefile is copied from a web page or an editor with “insert spaces” enabled; VS Code and most editors switch to tabs for files named Makefile, but not for build.mk unless you set the language. cat -A Makefile shows real tabs as ^I, which is the fastest way to check. GNU Make 3.82 and later also accept .RECIPEPREFIX = > to use a different character, though that makes the file unusual for other readers.

Issue 2: Missing dependencies

# ❌ No header dependency
main.o: main.cpp
	g++ -c main.cpp
# Even if util.h is changed, it will not be recompiled!
# ✅ Include header
main.o: main.cpp util.h config.h
	g++ -c main.cpp
# Automatic recompilation when util.h or config.h changes

Issue 3: Missing .PHONY

# ❌ If there is a file called clean, it will not be executed.
clean:
	rm -f *.o
# ✅ Use .PHONY
.PHONY: clean
clean:
	rm -f *.o
# Always runs even if there is a clean file

Issue 4: Parallel builds

# Sequential build (slow)
make
# Parallel build (4 tasks simultaneously)
make -j4
# Parallel build as many CPU cores
make -j$(nproc)

-j runs independent recipes at the same time, and Make decides what is independent purely from the dependency graph you wrote. A missing dependency that never mattered in serial builds, where rules happen to run in file order, becomes a race under -j: a generated header is used before it is written, or two rules write into a directory that does not exist yet. Builds that fail randomly with -j8 and pass with plain make almost always have such a missing edge. Output from parallel jobs also interleaves; GNU Make 4.0+ has --output-sync (-O) to group each job’s output.


Practical example: complete project

Project structure

project/
├── Makefile
├──include/
│ ├── calculator.h
│ └── utils.h
├── src/
│ ├── main.cpp
│ ├── calculator.cpp
│ └── utils.cpp
└── obj/
    └── (Created at build time)

Complete Makefile

# Compiler settings
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
INCLUDES = -I./include
LDFLAGS = -lpthread
# directory
SRC_DIR = src
OBJ_DIR = obj
INC_DIR = include
# file
SRCS = $(wildcard $(SRC_DIR)/*.cpp)
OBJS = $(patsubst $(SRC_DIR)/%.cpp,$(OBJ_DIR)/%.o,$(SRCS))
DEPS = $(OBJS:.o=.d)
# target
TARGET = myapp
# Debug build
ifeq ($(DEBUG),1)
    CXXFLAGS += -g -DDEBUG
else
    CXXFLAGS += -O2 -DNDEBUG
endif
# Default target
all: $(TARGET)
# linking
$(TARGET): $(OBJS)
	$(CXX) $^ -o $@ $(LDFLAGS)
	@echo "Build completed: $(TARGET)"
# Compile (automatically generate dependencies)
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp
	@mkdir -p $(OBJ_DIR)
	$(CXX) $(CXXFLAGS) $(INCLUDES) -MMD -c $< -o $@
# Include dependency files
-include $(DEPS)
# execution
run: $(TARGET)
	./$(TARGET)
# organize
clean:
	rm -rf $(OBJ_DIR) $(TARGET)
# Full rebuild
rebuild: clean all
# help
help:
	@echo "Available targets:"
	@echo " make - Release build"
	@echo " make DEBUG=1 - Debug build"
	@echo " make run - run after build"
	@echo " make clean - cleanup"
	@echo " make rebuild - full rebuild"
	@echo " make -j4 - Parallel builds (4)"
.PHONY: all run clean rebuild help

Usage:

make # Release build
make DEBUG=1 # Debug build
make -j4 # parallel build
make run # run
make clean # cleanup
make rebuild # rebuild
make help # help

This file combines the earlier pieces: sources are discovered under src/, objects go to obj/, headers come from include/ via -I, and -MMD keeps header dependencies accurate. Two details deserve attention. rebuild: clean all is not safe with -j, because Make may run clean and all in parallel; make clean && make is the reliable form. And CXXFLAGS += -g appends to a variable set in the file; if a user passes CXXFLAGS=... on the command line, the command-line value overrides the whole thing unless you write override CXXFLAGS += ....


Makefile or CMake

FeaturesMakefileCMake
ComplexitylowHigh
cross platformLIMITEDExcellent
learning curvegentleSteep
Direct controlHighlow
suitable projectsmall projectbig project

The comparison is less “Make versus CMake” than “which layer you write by hand”. CMake does not replace Make; it generates Makefiles (or Ninja files) for you and adds what a hand-written Makefile lacks: MSVC support, find_package for dependencies, per-target include paths, and compile_commands.json for editors. I still write a plain Makefile for small tools and exercises, and switch as soon as a second platform or a third-party library enters the picture.

Debugging a Makefile

When Make does something unexpected, make -n prints the commands it would run without running them, which shows immediately whether a variable expanded the way you thought. make --debug=b (basic) explains why each target is considered out of date, which is more readable than the very verbose make -d. To inspect a variable, add a temporary line such as $(info OBJS=$(OBJS)), which prints while the Makefile is being read. For slow rebuilds of unchanged code, putting ccache in front of the compiler (CXX = ccache g++) caches object files across make clean.

Make command

commandDescription
makeBuild default target
make cleanrun clean target
make -j44 task parallel build
make -nOutput only commands (dry-run)
make -Bforce rebuild all targets

Next steps



Frequently Asked Questions (FAQ)

Q. I edited a header but make says everything is up to date. Why?

A. Make rebuilds a target only when one of its listed prerequisites is newer, and a rule like %.o: %.cpp lists only the source file, not the headers it includes. Compile with -MMD -MP so the compiler writes a .d dependency file next to each object, and add -include $(DEPS) to the Makefile so those header dependencies are loaded on the next run. The -MP flag adds phony targets for each header, so deleting or renaming a header does not break the build.