Variables, Functions, and Patterns
Makefiles have variables, functions, and pattern rules — a whole small language that most engineers have only seen glimpses of. Learning the pieces beyond "targets and recipes" is the difference between writing Makefiles that scale and copy-pasting 100 lines of repetitive rules.
This lesson covers the four assignment operators (and why they're not the same), the built-in functions you will actually use, pattern rules, and the self-documenting help target that professional Makefiles ship with.
The assignment operators matter: = is lazy, := is immediate, ?= sets if unset, += appends. Pick the wrong one and your variables expand at the wrong time, usually breaking things in subtle ways.
The four assignment operators
NAME = value # lazy — re-evaluates every time NAME is expanded
NAME := value # immediate — evaluates once, when this line is read
NAME ?= value # conditional — sets only if NAME was previously unset
NAME += value # append — adds to the existing value (space-separated)
They look almost identical and do very different things.
= (lazy / recursive)
Value is re-expanded every time $(NAME) is referenced:
FOO = hello
BAR = $(FOO) world
FOO = bye # redefine
build:
@echo $(BAR) # prints "bye world" — $(FOO) is looked up NOW
Useful for "late-binding" variables whose value should reflect whatever is current at use time. Dangerous when you want a fixed value.
:= (immediate / simple)
Value is evaluated once, at the time of assignment:
FOO := hello
BAR := $(FOO) world
FOO := bye # redefine
build:
@echo $(BAR) # prints "hello world" — BAR was set when FOO was "hello"
Almost always what you want. Predictable. Makes subsequent changes to FOO not affect BAR.
Prefer := over = as a default. Use = only when you specifically want late binding.
?= (conditional)
Only assigns if the variable is not already set:
ENV ?= dev
# If ENV was set externally (e.g. `make ENV=prod build`), it stays prod.
# Otherwise it's dev.
The idiomatic way to supply a default that can be overridden from the command line or the environment.
+= (append)
Adds to the existing value, space-separated:
FLAGS := -Wall
FLAGS += -O2
FLAGS += -g
# FLAGS is now "-Wall -O2 -g"
Useful for building flag lists progressively.
If you use = with +=, the appended value is also lazy. Stick with := + += for simple-expansion variables — the most predictable combo.
Referencing variables
VAR := hello
$(VAR) # in a recipe or variable, expands to "hello"
${VAR} # equivalent — some people prefer curly braces
Variables can be referenced in targets, prerequisites, and recipes. In recipes, they expand when the recipe runs.
Variables vs shell variables
Inside a recipe, Make variables are expanded first; then the resulting text is passed to the shell, which handles $ itself.
build:
echo $(FOO) # Make expands $(FOO), shell sees the literal value
echo $$FOO # $$ is Make-escape for a literal $, shell sees $FOO
To pass a literal $ to the shell, double it. This is how you reference shell variables vs Make variables.
Built-in functions — the useful ones
Make has ~30 built-in functions. The ones you'll use most:
$(shell cmd) — run a shell command
Captures the output of a shell command at parse time:
VERSION := $(shell git describe --tags --always)
NUM_CORES := $(shell nproc)
build:
@echo "building version $(VERSION) with $(NUM_CORES) cores"
Runs once when the Makefile is parsed. The result is stored as a string.
$(wildcard pattern) — glob matching
Expand a glob to a space-separated list:
SOURCES := $(wildcard src/*.c)
# e.g. "src/main.c src/utils.c src/io.c"
$(patsubst pattern,replacement,text) — pattern substitution
SOURCES := src/main.c src/utils.c
OBJECTS := $(patsubst %.c,%.o,$(SOURCES))
# OBJECTS = "src/main.o src/utils.o"
Turns sources into corresponding object files. The % is a wildcard that matches anything.
Shorthand form:
OBJECTS := $(SOURCES:.c=.o)
# Same result, shorter syntax
$(foreach var,list,expr) — iterate
DIRS := src test lib
RESULT := $(foreach d,$(DIRS),$(d)/out)
# RESULT = "src/out test/out lib/out"
$(addprefix prefix,list) and $(addsuffix suffix,list)
FILES := main utils
OBJECTS := $(addsuffix .o,$(FILES)) # main.o utils.o
PATHS := $(addprefix build/,$(OBJECTS)) # build/main.o build/utils.o
$(dir names) and $(notdir names) and $(basename names) and $(suffix names)
PATH := src/sub/file.c
$(dir $(PATH)) # "src/sub/"
$(notdir $(PATH)) # "file.c"
$(basename $(PATH)) # "src/sub/file"
$(suffix $(PATH)) # ".c"
$(filter pattern,list) and $(filter-out pattern,list)
ALL := a.c b.h c.c d.h
$(filter %.c,$(ALL)) # "a.c c.c"
$(filter-out %.h,$(ALL)) # "a.c c.c"
$(strip text)
Removes leading/trailing whitespace and collapses internal whitespace.
$(strip a b c ) # "a b c"
Pattern rules
Pattern rules let one rule cover many targets. The % is a wildcard.
# Build any .o from the corresponding .c
%.o: %.c
gcc -c -o $@ $<
Used by Make automatically: when you ask for main.o, Make looks at the pattern rule, matches %.o to main.o (so % is main), and applies the rule with main.c as $<.
Common recipe with $@ and $<
%.min.js: %.js
terser $< -o $@
%.gz: %
gzip -k $<
Reading: target uses $@; first dep uses $<. The pattern rule matches any target ending in .min.js and builds from the same base with .js.
Using pattern rules for a list of targets
SOURCES := $(wildcard src/*.c)
OBJECTS := $(SOURCES:.c=.o)
all: $(OBJECTS)
%.o: %.c
gcc -c -o $@ $<
Make walks OBJECTS, notices each is a .o, applies the pattern rule, and builds from its .c counterpart. One rule, N targets.
Conditional directives
ifeq ($(ENV),prod)
FLAGS := -O2 -DNDEBUG
else
FLAGS := -g -O0
endif
ifneq ($(OS),Windows_NT)
RM := rm -f
else
RM := del /Q
endif
ifdef DEBUG
EXTRA := --verbose
endif
Conditional variables — pick values based on environment, target OS, feature flags, etc.
.PHONY meta-target
Already covered. Here's the common pattern:
.PHONY: help build test lint clean install run
One line near the top, every command-like target listed. Updated as targets are added.
The self-documenting help target — the canonical pattern
Every good Makefile has a help target that lists all targets with their descriptions. The magic: the help target reads the Makefile itself and picks out ## description comments.
.PHONY: help
.DEFAULT_GOAL := help
help: ## Show this help message
@awk 'BEGIN {FS = ":.*?## "} \
/^[a-zA-Z_-]+:.*?## / \
{printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' \
$(MAKEFILE_LIST)
build: ## Build the binary
@go build -o bin/app .
test: ## Run tests with race detection
@go test -race ./...
lint: ## Run golangci-lint
@golangci-lint run
clean: ## Remove build artifacts
@rm -rf bin/
deploy: ## Deploy to production (requires KUBECONFIG)
@kubectl apply -f manifests/
Running make:
build Build the binary
test Run tests with race detection
lint Run golangci-lint
clean Remove build artifacts
deploy Deploy to production (requires KUBECONFIG)
help Show this help message
The awk command parses any line matching target: ... ## description and formats it. New targets appear automatically once you add the ## doc comment.
Copy the help target into every Makefile you write. It takes 30 seconds and the payoff is huge — every new team member finds the targets without reading the Makefile.
Including variables from the environment
All environment variables are automatically available as Make variables:
build:
@echo "USER is $(USER)"
@echo "PATH is $(PATH)"
You can override any Make variable from the command line:
make VERSION=v2.0 build
Inside the Makefile, that overrides whatever VERSION := says. Command-line assignments win.
Exporting to subprocesses
Variables in a Makefile are NOT exported to recipe shells by default:
NAME := myapp
show:
@echo "NAME in make: $(NAME)"
@echo "NAME in shell: $$NAME" # empty!
Export with export:
export NAME := myapp
show:
@echo "NAME in shell: $$NAME" # now "myapp"
Putting it together — a realistic Makefile
# Project Makefile
# --- variables ---
BINARY := myapp
VERSION := $(shell git describe --tags --always --dirty)
GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*')
BUILD_DIR := bin
GOFLAGS := -ldflags "-X main.Version=$(VERSION)"
ENV ?= dev # default; override with `make ENV=prod ...`
# --- meta ---
.PHONY: help build test lint clean run docker-build install
.DEFAULT_GOAL := help
# --- targets ---
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} \
/^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' \
$(MAKEFILE_LIST)
build: $(BUILD_DIR)/$(BINARY) ## Build the binary
$(BUILD_DIR)/$(BINARY): $(GO_FILES) | $(BUILD_DIR)
@echo "building $(VERSION)"
@go build $(GOFLAGS) -o $@ .
$(BUILD_DIR):
@mkdir -p $@
test: ## Run tests
@go test -race ./...
lint: ## Run linters
@golangci-lint run
clean: ## Remove build artifacts
@rm -rf $(BUILD_DIR)
run: build ## Run the app
@./$(BUILD_DIR)/$(BINARY)
docker-build: ## Build the Docker image
@docker build -t $(BINARY):$(VERSION) -t $(BINARY):latest .
install: build ## Install to /usr/local/bin
@install -m 0755 $(BUILD_DIR)/$(BINARY) /usr/local/bin/
Features:
:=for all the immediate assignments.?=forENV(overridable).$(shell ...)for version from git.$(wildcard)viafind(covers subdirectories).- Proper file target for the binary (caches correctly).
| $(BUILD_DIR)as an order-only prerequisite so the dir exists..PHONYlisted at the top.- Help target self-documents from
## comments.
This is the shape of a mid-sized project Makefile.
Pattern rules in practice — a multi-file build
Build every .c to .o, then link them into a binary:
SOURCES := $(wildcard src/*.c)
OBJECTS := $(SOURCES:.c=.o)
BINARY := app
.PHONY: all clean
.DEFAULT_GOAL := all
all: $(BINARY)
$(BINARY): $(OBJECTS)
gcc -o $@ $^
%.o: %.c
gcc -c -o $@ $<
clean:
rm -f $(OBJECTS) $(BINARY)
No matter how many .c files are added to src/, this Makefile builds them all without modification. That is the power of pattern rules.
Header dependencies — the -MMD trick
A C file depends on its headers, but you shouldn't hand-list every header. gcc -MMD generates dependency files automatically:
SOURCES := $(wildcard src/*.c)
OBJECTS := $(SOURCES:.c=.o)
DEPS := $(OBJECTS:.o=.d)
%.o: %.c
gcc -c -MMD -MP -o $@ $<
-include $(DEPS) # include auto-generated .d files
Now when a header changes, Make correctly rebuilds the .o files that use it. Advanced, but ubiquitous in serious C/C++ builds.
Common variable mistakes
Mistake 1: = where you meant :=
VERSION = $(shell git describe) # re-runs git every time VERSION is expanded!
If you reference $(VERSION) in 10 places, you run git describe 10 times. Use :=:
VERSION := $(shell git describe) # runs once
Mistake 2: unquoted shell variables in recipes
NAME := my file
deploy:
cp $(NAME) /target # word-splits on space
Variables can contain spaces. Quote them as you would shell vars:
deploy:
cp "$(NAME)" /target
Mistake 3: forgetting $$ for shell variables
greet:
name="Alice"
echo $name # Make saw $n and expanded it to empty; shell sees "ame"
Double the $:
greet:
name="Alice"; echo $$name
And chain them with ; or &&, since each line is a new shell:
greet:
name="Alice" && echo $$name
Quiz
You write: VERSION = $(shell git describe --tags) and reference $(VERSION) four times in different targets. What happens each time you run make?
What to take away
:=is the right default. Use=only when you want late binding,?=for overridable defaults,+=to append.$(shell cmd)runs commands at parse time. Use:=so it runs once.$(wildcard),$(patsubst),$(foreach),$(addprefix),$(filter)— memorize these. They cover 90% of Make function usage.- Pattern rules (
%.o: %.c) let one rule cover N similar targets. Combined with$(wildcard), they scale to any number of files. - Self-documenting
helptarget via awk parsing## descriptioncomments. Copy it into every Makefile. - Order-only prerequisites (
| dir) for directories and other things that should exist but shouldn't trigger rebuilds. $$to escape$for the shell. Shell variables in recipes need doubling.- Chain recipe lines with
&&if they share state, or use.ONESHELL:.
Next lesson: production Makefile conventions — the build/test/lint/ci/deploy pattern, Docker integration, and when to reach for just or task instead.