Make Cheatsheet

Recursive Make

Use this Make reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

$(MAKE) and -C

A recursive make is one makefile invoking another as a sub-process. Always call it as $(MAKE), never as a literal make, so that the sub-make inherits the same binary, the flags, and the job server.

SUBDIRS := liba libb

.PHONY: all $(SUBDIRS)
all: $(SUBDIRS)

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

libb: liba          # ordering between sub-builds
make -C liba
make[1]: Entering directory '/tmp/rec/liba'
make[1]: Leaving directory '/tmp/rec/liba'
make -C libb

- -C dir chdir's before reading the makefile, so relative paths inside the sub-makefile stay relative to that directory. $(CURDIR) is the sub-make's own directory. - MAKELEVEL is 0 at the top and increments per level. Guard top-level-only logic with ifeq ($(MAKELEVEL),0). - make[1]: Entering directory lines come free. Silence them with --no-print-directory, but keep them in CI, because a compiler error's relative path is meaningless without them.

The + recipe prefix and the job server

-j is implemented with a job server that sub-makes must join, otherwise each one starts its own pool and you get N × N parallel jobs.

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

- Any recipe line that literally contains $(MAKE) or ${MAKE} is already treated as recursive, and runs even under -n, -t, and -q. - The + prefix is what you need when the sub-make is hidden behind a wrapper, a shell script, or a variable that does not spell out $(MAKE). - MAKEFLAGS carries the job server to children. Inspecting it in a sub-make under -j2 shows something like -j2 --jobserver-auth=fifo:/tmp/GMfifo13195. - GNU make 4.4 switched from a pipe pair to a named FIFO, which fixed most of the historical warning: jobserver unavailable: using -j1 noise. On older versions that warning means a recipe launched a make that make did not recognize as recursive, and + is the fix.

Never write -j inside a recipe's $(MAKE) call. Pass it once on the outer command line and let it propagate.

Passing variables down

Variables do not automatically reach a sub-make. Three ways to send them, in increasing bluntness.

export PREFIX := /opt/thing     # this one variable, to recipes and sub-makes
export                          # every variable, from this point on
.EXPORT_ALL_VARIABLES:          # every variable, whole file
unexport CFLAGS                 # opt one back out
# or on the sub-make command line, which the sub-makefile cannot override
$(SUBDIRS):
	$(MAKE) -C $@ CFLAGS='$(CFLAGS)'
MethodSub-make sees it asSub-makefile can override
export VARenvironmentyes
$(MAKE) VAR=xcommand lineno, unless override
MAKEFLAGS += VAR=xcommand line, to every levelno
nothingnot set—

Two traps. First, an exported variable becomes an environment variable for every recipe, so exporting something named PATH or SHELL changes how commands run. Second, command-line variables propagate to all deeper levels automatically, which makes a deep override impossible to shake off.

Variables make always propagates: MAKEFLAGS, MFLAGS, MAKELEVEL, MAKE, and CURDIR. MAKEFILE_LIST and .DEFAULT_GOAL are per-invocation and do not.

The -C versus cd distinction

# Wrong: a new shell per line, so the cd is gone by line two
bad:
	cd liba
	$(MAKE)

# Works, but loses the job server and the directory banner
ok:
	cd liba && $(MAKE)

# Correct
good:
	$(MAKE) -C liba

Why non-recursive is usually better

Each sub-make sees only its own directory, and that single fact causes every recursive-make problem. This is the argument of Peter Miller's 1997 paper Recursive Make Considered Harmful, and it still holds.

ProblemWhy it happens
Wrong build orderThe top level guesses dependencies between subdirectories
Under-parallelizedlibb waits for all of liba, not just the parts it needs
Repeated workEach level re-reads makefiles and re-runs $(shell) probes
Incorrect incremental buildsA cross-directory header edge no sub-make can see
-j fragilityA missed + or a wrapper script breaks the job server
Slow no-op buildsN sub-makes to decide there is nothing to do

The non-recursive alternative: one make process reads included fragments from every directory and builds one complete dependency graph.

# top-level Makefile
MODULES := liba libb app
include $(patsubst %,%/module.mk,$(MODULES))

.PHONY: all
all: $(ALL_TARGETS)
# liba/module.mk , paths relative to the top level
liba_SRC := $(wildcard liba/*.c)
liba_OBJ := $(liba_SRC:.c=.o)
ALL_TARGETS += liba/liba.a

liba/liba.a: $(liba_OBJ)
	$(AR) rcs $@ $^

The rules: every path is written relative to the top level, every variable is prefixed with the module name to avoid collisions, and fragments append to shared lists with += rather than assigning.

When recursion is still the right call:

  • The sub-directory is a genuinely separate project with its own build system.
  • You are driving a different tool entirely (cargo, go build, cmake).
  • The tree is vendored third-party code you do not want to model.

In those cases mark the target .PHONY, prefix the line with +, and accept that make cannot reason about what is inside.

A safe recursive skeleton

MAKEFLAGS += --no-print-directory
SUBDIRS   := liba libb app
export PREFIX ?= /usr/local

.PHONY: all clean $(SUBDIRS)
all: $(SUBDIRS)

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

app: liba libb

clean:
	+$(MAKE) -C liba clean
	+$(MAKE) -C libb clean
	+$(MAKE) -C app  clean
make -j8                  # parallel across and within subdirectories
make TARGET=test          # forward a goal to every subdirectory
make -k                   # keep going, build every subdirectory that can

$(TARGET) forwarding beats writing one pass-through target per goal, and it stays correct when a subdirectory does not define the goal only if you add a catch-all there.