Make Cheatsheet

Variables

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

The five assignment flavors

OperatorNameWhen the right side is expanded
=RecursiveEvery time the variable is used
:=SimpleOnce, at the point of assignment
::=Simple (POSIX)Same as :=, standardized in POSIX 2024
?=ConditionalLike =, but only if the variable is not already set
+=AppendKeeps the flavor of the original assignment
!=ShellRuns the shell once, assigns the trimmed output (GNU make 4.0+)
REC   = $(LATER)      # picks up LATER whenever REC is referenced
SIM  := $(LATER)      # LATER is empty right now, so SIM is empty forever
LATER = value
DEF  ?= fallback      # only if DEF was not set on the command line or in env
LIST  = a
LIST += b             # "a b"
NOW  != date +%Y      # "2026"

Referencing those prints REC=[value] SIM=[] DEF=[fallback] LIST=[a b]. := sees only what is already defined above it. = sees the final value.

!= is silently empty on GNU make 3.81, which is still what /usr/bin/make is on macOS. Use $(shell ...) with := if you need to support it.

When recursive expansion bites

Recursive = is late-binding, which is usually what you want for flags and usually wrong for anything expensive.

# Works: INCLUDES is defined later, and = defers the lookup.
CFLAGS   = $(INCLUDES) -O2
INCLUDES = -Iinclude

# Broken: := froze CFLAGS before INCLUDES existed.
CFLAGS  := $(INCLUDES) -O2
INCLUDES = -Iinclude

The expensive case, a shell call re-run on every reference:

LAZY  = $(shell git rev-parse --short HEAD)   # runs once per use
EAGER := $(shell git rev-parse --short HEAD)  # runs exactly once

Three references to LAZY fork three git processes. In a large makefile that is measurable, and in a recipe inside a loop it is painful.

Self-reference is only legal with :=.

CFLAGS = $(CFLAGS) -O2     # *** Recursive variable 'CFLAGS' references itself
CFLAGS := $(CFLAGS) -O2    # fine, appends once

Rule of thumb: default to :=, reach for = only when the value genuinely must be resolved later, and use ?= for anything a caller should be able to override.

Precedence and where a value came from

From lowest priority to highest:

  1. Built-in defaults (CC, RM, AR, ...)
  2. Environment variables
  3. Assignments in the makefile
  4. Command line, make CFLAGS=-O0
  5. override CFLAGS := ... in the makefile

Command-line assignments beat makefile assignments, which is why ?= and override exist. make -e flips environment above makefile.

override CFLAGS += -Wall     # append even to a command-line value

$(origin VAR) reports the source, useful in a guard:

$(info CC came from $(origin CC))     # default | environment | file
                                      # | command line | override | undefined
ifeq ($(origin PREFIX),undefined)
  PREFIX := /usr/local
endif

Target-specific and pattern-specific variables

A variable can be scoped to one target and its prerequisites, or to every target matching a pattern.

debug: CFLAGS := -O0 -g -DDEBUG
debug: app

%.min.js: CFLAGS := --compress

Add private so the value does not leak down to prerequisites:

debug: private CFLAGS := -O0 -g

Target-specific values are why make debug can reuse the same compile rules with different flags instead of duplicating them.

Shell variables need $$

Make expands $ first, so a literal dollar sign in a recipe is $$. This is the second most common make bug after tabs.

show:
	@echo "make var:  $(HOME)"      # expanded by make
	@echo "shell var: $$HOME"       # passed through as $HOME
	@for f in *.c; do echo "$$f"; done

Each recipe line is its own shell, so a shell variable set on one line is gone on the next. Join with && or ; inside one line, or turn on .ONESHELL.

bad:
	x=5
	@echo "[$$x]"            # prints [] , new shell

good:
	x=5 && echo "[$$x]"      # prints [5]

In awk and sed scripts inside recipes the same rule applies, so field references become $$1 and $$2.

$(shell ...) versus backticks

Both run a command, at different times and with different results.

$(shell cmd)` cmd ` in a recipe
Who runs itmake, while parsing or expandingthe shell, when the line runs
Whenbefore any recipe executesat recipe time
Newlinescollapsed to single spacespreserved
Trailing newlinestrippedstripped by the shell
Available in variable assignmentsyesno
Sees files created by earlier recipesnoyes
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)

list:
	@echo "generated files: `ls build`"    # build/ exists only now

Use $(shell ...) for configuration read once at startup, and backticks or $$(...) for anything that depends on what the build just produced. Check .SHELLSTATUS for the exit code of the last $(shell ...) (GNU make 4.2+).

Multi-line values with define

define BANNER
=====================
 building $(NAME)
=====================
endef
export BANNER

define compile_rule
$(1).o: $(1).c
	$(CC) $(CFLAGS) -c -o $$@ $$<
endef

$(eval $(call compile_rule,main))

banner:
	@echo "$$BANNER"

Inside a define destined for eval, automatic variables need $$ so they survive the first expansion pass.