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:

CommandBehaviorTime (example)
makeOne file at a time60 seconds
make -j4Four files at once~16 seconds
make -j8Eight 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.txt

Problem 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 build

The -l Flag: Load-Based Limiting

make -j8 -l4.0    # Start up to 8 jobs, but pause if system load exceeds 4.0

This 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 -n

This 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 | less

This 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-variables

Warns 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:

  1. make -n — Are the right commands being generated?
  2. make -p | grep VARIABLE — Does the variable have the value you expect?
  3. Add $(info ...) before the problem area — Is the file list correct?
  4. 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 test

Without .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/%.o

When would Make delete intermediate files? If you have a chain like .c.omyapp, 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 rules

This 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 build

Without |: 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_modules

How does this work?

  1. npm install should only run when package.json changes
  2. After running npm install, we touch .stamps/npm-install — this creates (or updates) an empty file
  3. Next time Make checks, it compares .stamps/npm-install's timestamp to package.json's timestamp
  4. If package.json is newer, the stamp is "out of date" and npm install runs again
  5. If package.json hasn'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 cores

4. 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 -j4 runs 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 =, use wildcard instead 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 *.o

Add:

  1. .DELETE_ON_ERROR and .SUFFIXES
  2. Pattern rules with automatic variables
  3. A build/ output directory with order-only prerequisites
  4. Parallel-safe dependencies
  5. Auto-generated header dependencies (-MMD -MP)
  6. A make -n friendly 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_ERROR prevents corrupt .o files from failed builds
  • .SUFFIXES: clears built-in rules for a clean slate
  • := (simple) assignment instead of = (recursive) for all variables
  • Pattern rule $(BUILD)/%.o: %.c replaces two explicit rules
  • $^, $<, $@ automatic variables replace hardcoded file names
  • | $(BUILD) order-only prerequisite prevents unnecessary rebuilds
  • -MMD -MP auto-generates header dependency tracking
  • -include $(DEPS) reads those dependencies
  • Build output goes to build/ instead of cluttering the source directory
Was this page helpful?
SR

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 →