Advanced Techniques
Summary: in this tutorial, you will learn master parallel builds, makefile debugging, special targets, order-only prerequisites, and performance optimization for professional build systems.
Advanced Techniques
You now know how to write Makefiles — variables, pattern rules, functions, conditionals, and multi-directory organization. This tutorial covers the techniques that separate a working Makefile from a professional one: parallel builds, debugging, special targets, and performance tricks.
What you'll learn in this tutorial:
- How to run parallel builds with
-j(and why it matters) - How to debug a misbehaving Makefile
- Special targets like
.PHONY,.PRECIOUS,.DELETE_ON_ERROR, and.DEFAULT_GOAL - Order-only prerequisites (the
|syntax) - Stamp files for tracking non-file actions
- Performance tips for large projects
Prerequisite: This tutorial assumes you're comfortable with pattern rules, variables, functions, and multi-directory project organization from the previous tutorials. If something doesn't make sense, go back and review those first.
Parallel Builds
By default, Make runs one command at a time. On a multi-core machine, this is wasteful. The -j flag tells Make to run multiple commands simultaneously:
make -j4 # Run up to 4 commands at the same time
make -j8 # Run up to 8 commands at the same time
make -j$(nproc) # Use all available CPU cores (Linux)
make -j$(sysctl -n hw.ncpu) # Use all available cores (macOS)Why It Matters
Compiling a project with 100 source files takes vastly different amounts of time:
| Command | Behavior | Time (example) |
|---|---|---|
make | One file at a time | 60 seconds |
make -j4 | Four files at once | ~16 seconds |
make -j8 | Eight files at once | ~9 seconds |
How does Make know what can run in parallel? It uses the dependency graph. If main.o and utils.o don't depend on each other, Make can build them at the same time. But if myapp depends on both main.o and utils.o, Make waits for both to finish before linking.
This is why correct dependencies are critical — incorrect or missing dependencies cause random build failures with -j.
Making Your Makefile Parallel-Safe
Most Makefiles work fine with -j if the dependencies are correct. Common issues:
Problem 1: Missing dependencies
# WRONG — Make might try to link before objects are built
all: link compile
compile:
$(CC) -c src/*.c
link:
$(CC) *.o -o myapp# RIGHT — explicit dependencies ensure correct order
OBJS := main.o utils.o
myapp: $(OBJS)
$(CC) $^ -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@Problem 2: Multiple rules writing to the same file
# WRONG — two rules might write to output.txt simultaneously
report: report_header report_body
report_header:
echo "=== Report ===" > output.txt
report_body:
echo "Data here" >> output.txtProblem 3: Directory creation races
# WRONG — multiple parallel compilations might try to create build/ at once
build/%.o: src/%.c
mkdir -p build
$(CC) $(CFLAGS) -c $< -o $@# RIGHT — use order-only prerequisites (explained below)
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
build:
mkdir -p buildThe -l Flag: Load-Based Limiting
make -j8 -l4.0 # Start up to 8 jobs, but pause if system load exceeds 4.0This prevents Make from overwhelming your system when other processes are running.
Debugging Makefiles
When your Makefile doesn't behave as expected, here are the tools to figure out what's going wrong:
--dry-run (-n): See What Would Happen
make -nThis prints every command Make would run, without actually executing anything. Invaluable for checking if your rules are correct:
$ make -n
gcc -Wall -Wextra -c src/main.c -o build/main.o
gcc -Wall -Wextra -c src/utils.c -o build/utils.o
gcc build/main.o build/utils.o -o build/myapp--print-data-base (-p): See All Rules and Variables
make -p | lessThis dumps Make's entire internal state — every variable, every rule (including built-in ones), and their values. It's verbose but comprehensive.
Filter it to find specific information:
# Find the value of CFLAGS
make -p | grep "^CFLAGS"
# Find rules for .o files
make -p | grep -A2 "\.o:"--debug: Trace Make's Decision Process
make --debug=basic # Shows which rules are considered and why
make --debug=verbose # Shows everything including automatic variables
make --debug=all # Maximum detail (very verbose!)Example output with --debug=basic:
Considering target file 'myapp'.
File 'myapp' does not exist.
Considering target file 'main.o'.
File 'main.o' does not exist.
Must remake target 'main.o'.
$(info ...), $(warning ...), and $(error ...)
Add these anywhere in your Makefile to print values during parsing:
SRCS := $(wildcard src/*.c)
$(info Found sources: $(SRCS))
OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))
$(info Generated objects: $(OBJS))--warn-undefined-variables
make --warn-undefined-variablesWarns you when you reference a variable that hasn't been defined. Catches typos like $(CFLAG) instead of $(CFLAGS).
The @echo Technique
Add @echo lines to your recipes to see what values are being used:
build/%.o: src/%.c | build
@echo "Compiling: $< → $@"
@echo " CFLAGS = $(CFLAGS)"
$(CC) $(CFLAGS) -c $< -o $@Quick debugging checklist:
make -n— Are the right commands being generated?make -p | grep VARIABLE— Does the variable have the value you expect?- Add
$(info ...)before the problem area — Is the file list correct? make --debug=basic— Why is (or isn't) Make rebuilding this target?
Special Targets
Special targets change Make's behavior. They start with a dot (.) and aren't actual files.
.PHONY — Not Real Files (Review)
You've already seen this — it tells Make these targets aren't actual files:
.PHONY: all clean install testWithout .PHONY, if a file named clean existed in your directory, make clean would say "nothing to do" because the file is already up to date.
.DEFAULT_GOAL — Set the Default Target
Normally, Make runs the first target in the file. You can override this:
.DEFAULT_GOAL := all
# Even though "clean" comes first, "make" will run "all"
clean:
rm -rf build
all: myapp
@echo "Done!".DELETE_ON_ERROR — Clean Up Failed Builds
If a command fails, Make normally leaves partial output files behind. .DELETE_ON_ERROR removes the target file if the recipe fails:
.DELETE_ON_ERROR:
build/%.o: src/%.c
$(CC) $(CFLAGS) -c $< -o $@Always include .DELETE_ON_ERROR. Without it, a failed compilation might leave a corrupt .o file. On the next make, Make sees the .o file exists, assumes it's correct, and skips rebuilding it — giving you mysterious errors. .DELETE_ON_ERROR prevents this.
.PRECIOUS — Don't Delete Intermediate Files
Make automatically deletes "intermediate" files — files that are created as a byproduct of building something else. .PRECIOUS prevents this:
.PRECIOUS: build/%.oWhen would Make delete intermediate files? If you have a chain like .c → .o → myapp, and the .o file only exists as a stepping stone, Make might delete the .o files after building myapp. This rarely happens in practice with explicitly listed prerequisites, but .PRECIOUS guarantees they're kept.
.SILENT — Suppress Command Printing
Instead of using @ before every command:
.SILENT: clean install
clean:
rm -rf build # Make won't print this command
install:
cp myapp /usr/local/bin # Or this one.SUFFIXES — Clear Built-in Rules
Make comes with many built-in rules (like "compile .c to .o"). If you want a clean slate:
.SUFFIXES: # Clears all built-in suffix rulesThis can speed up Make slightly because it won't try to match built-in patterns.
Order-Only Prerequisites
Regular prerequisites trigger a rebuild when they change. Order-only prerequisites (after |) only ensure something exists — they don't trigger rebuilds based on timestamps:
build/%.o: src/%.c | build
$(CC) $(CFLAGS) -c $< -o $@
build:
mkdir -p buildWithout |: Every time anything changes in the build/ directory (like adding a new .o file), the directory's timestamp updates, and Make would recompile everything.
With |: Make creates build/ if it doesn't exist, but doesn't care about its timestamp.
Rule of thumb: Use | (order-only) for directories and anything else where you need existence but not freshness checking.
Stamp Files
Some actions don't produce a file — like running tests, deploying, or downloading dependencies. Make tracks what needs rebuilding by checking file timestamps, so what do you do when there's no file?
Stamp files (also called "sentinel files") are empty files whose existence and timestamp says "this action was done."
.stamps/npm-install: package.json
npm install
@mkdir -p .stamps
@touch $@
.stamps/test: $(SRCS) .stamps/npm-install
npm test
@mkdir -p .stamps
@touch $@
.stamps/deploy: .stamps/test
./deploy.sh
@mkdir -p .stamps
@touch $@
clean:
rm -rf .stamps build node_modulesHow does this work?
npm installshould only run whenpackage.jsonchanges- After running
npm install, wetouch .stamps/npm-install— this creates (or updates) an empty file - Next time Make checks, it compares
.stamps/npm-install's timestamp topackage.json's timestamp - If
package.jsonis newer, the stamp is "out of date" andnpm installruns again - If
package.jsonhasn't changed, the stamp is newer and Make skips it
Performance Tips
1. Use Simple Assignment (:=) for Expensive Operations
# BAD — $(shell git rev-parse HEAD) runs EVERY time GIT_HASH is referenced
GIT_HASH = $(shell git rev-parse --short HEAD)
# GOOD — runs once when the Makefile is parsed
GIT_HASH := $(shell git rev-parse --short HEAD)2. Avoid Unnecessary $(shell ...) Calls
# BAD — spawns a shell process just to list files
SRCS := $(shell find src -name '*.c')
# BETTER — uses Make's built-in function (no shell process needed)
SRCS := $(wildcard src/*.c)Use $(shell find ...) only when you need recursive directory searching that wildcard can't handle.
3. Use -j for Parallel Builds
Always test your Makefiles with -j:
make -j$(nproc) # Use all cores4. Minimize Recursive Make Calls
Each $(MAKE) -C dir launches a new Make process, which has startup overhead. The non-recursive approach (one Make with include) is faster.
5. Use .DELETE_ON_ERROR and Correct Dependencies
Incorrect dependency tracking wastes time because:
- Missing dependencies cause incorrect builds that you have to debug
- Over-specified dependencies cause unnecessary rebuilds
Putting It All Together
Here's a production-quality Makefile skeleton with all the best practices:
# === Best Practices Makefile ===
# Safety: delete targets on error, no built-in rules
.DELETE_ON_ERROR:
.SUFFIXES:
# Configuration
CC := gcc
CFLAGS := -Wall -Wextra -MMD -MP
BUILD := build
DEBUG ?= 0
ifeq ($(DEBUG),1)
CFLAGS += -g -O0 -DDEBUG
BUILD := build/debug
else
CFLAGS += -O2
BUILD := build/release
endif
# File discovery
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,$(BUILD)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)
TARGET := $(BUILD)/myapp
# Default target
.DEFAULT_GOAL := all
.PHONY: all clean info
all: $(TARGET)
@echo "Built: $<"
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) $^ -o $@
$(BUILD)/%.o: src/%.c | $(BUILD)
$(CC) $(CFLAGS) -c $< -o $@
$(BUILD):
@mkdir -p $@
clean:
rm -rf build
info:
@echo "CC: $(CC)"
@echo "CFLAGS: $(CFLAGS)"
@echo "SRCS: $(SRCS)"
@echo "OBJS: $(OBJS)"
@echo "TARGET: $(TARGET)"
# Auto-generated dependencies
-include $(DEPS)Summary
Here's what you've learned:
make -j4runs four commands in parallel — always test with-j- Debugging:
make -n(dry run),make -p(print database),$(info ...)(print values) .DELETE_ON_ERROR— always include this; prevents corrupt partial files.PHONY— marks targets that aren't real files.DEFAULT_GOAL— changes which target runs by default.PRECIOUS— prevents deletion of intermediate files- Order-only prerequisites (
| dir) — ensure existence without timestamp checking - Stamp files — track non-file actions (tests, installs, deploys) with empty files
- Performance: prefer
:=over=, usewildcardinstead of$(shell find), use-j
In the next (and final!) tutorial, you'll see complete real-world Makefiles for different types of projects.
Add Professional Features to a Makefile
Take this basic Makefile and improve it with the techniques from this tutorial:
CC = gcc
CFLAGS = -Wall
myapp: main.o utils.o
$(CC) main.o utils.o -o myapp
main.o: main.c
$(CC) $(CFLAGS) -c main.c -o main.o
utils.o: utils.c
$(CC) $(CFLAGS) -c utils.c -o utils.o
clean:
rm -f myapp *.oAdd:
.DELETE_ON_ERRORand.SUFFIXES- Pattern rules with automatic variables
- A
build/output directory with order-only prerequisites - Parallel-safe dependencies
- Auto-generated header dependencies (
-MMD -MP) - A
make -nfriendly structure (no side effects in variable definitions)
Show Solution
# Safety settings
.DELETE_ON_ERROR:
.SUFFIXES:
# Configuration (simple assignment for predictability)
CC := gcc
CFLAGS := -Wall -Wextra -MMD -MP
BUILD := build
# File lists
SRCS := main.c utils.c
OBJS := $(addprefix $(BUILD)/,$(SRCS:.c=.o))
DEPS := $(OBJS:.o=.d)
TARGET := $(BUILD)/myapp
# Default target
.DEFAULT_GOAL := all
.PHONY: all clean
all: $(TARGET)
@echo "Built: $(TARGET)"
# Link — depends on all objects (parallel-safe)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $^ -o $@
# Compile — pattern rule with order-only directory prerequisite
$(BUILD)/%.o: %.c | $(BUILD)
$(CC) $(CFLAGS) -c $< -o $@
# Create build directory
$(BUILD):
@mkdir -p $@
clean:
rm -rf $(BUILD)
# Include auto-generated header dependencies
-include $(DEPS)Improvements made:
.DELETE_ON_ERRORprevents corrupt.ofiles from failed builds.SUFFIXES:clears built-in rules for a clean slate:=(simple) assignment instead of=(recursive) for all variables- Pattern rule
$(BUILD)/%.o: %.creplaces two explicit rules $^,$<,$@automatic variables replace hardcoded file names| $(BUILD)order-only prerequisite prevents unnecessary rebuilds-MMD -MPauto-generates header dependency tracking-include $(DEPS)reads those dependencies- Build output goes to
build/instead of cluttering the source directory
Written by the ShellRAG Team
The ShellRAG editorial team writes practical, beginner-friendly Makefile tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.
Learn more about us →