Skip to content

Make & Build Systems — Footguns

Mistakes that silently break builds, cause data loss, or waste hours of debugging time. Every one of these has bitten production teams.


1. Tabs vs Spaces in Recipes

Recipe lines must begin with a tab character. Spaces will not work. This is the single most common Make error for newcomers, and the error message is cryptic:

Makefile:5: *** missing separator.  Stop.

What happens: Make refuses to parse the file. The error message gives no hint about whitespace. You stare at a perfectly reasonable-looking rule and see nothing wrong.

Why: Stuart Feldman made this design choice in 1976 and later admitted he would have fixed it, but by then too many Makefiles existed. It cannot be changed without breaking every existing Makefile on Earth.

How to avoid: Configure your editor to use tabs in Makefiles. Add to .editorconfig:

[Makefile]
indent_style = tab

Copy-pasting from documentation, web pages, or Slack often converts tabs to spaces silently. If a Makefile stops parsing after a paste, check whitespace first:

# Reveal tabs (shown as ^I) vs spaces
cat -A Makefile | head -20

2. Not Declaring .PHONY

You have a target called test. Someone creates a file called test in the directory (maybe a test output file, maybe a directory). Now make test says "nothing to be done for test" and your tests never run.

# Makefile
test:
    pytest -v tests/

# Then someone does:
# $ mkdir test   # or touch test
# $ make test
# make: 'test' is up to date.

What happens: Make compares the test file's timestamp against its prerequisites. Since the file exists and is newer than its (nonexistent) prerequisites, the recipe is skipped entirely.

Why: Make's core logic is file-timestamp-based. If a target name matches an existing file and there is no .PHONY declaration, Make treats it as a file target.

How to avoid: Always declare non-file targets as phony:

.PHONY: test lint build deploy clean help all

# Or group them at the top of your Makefile
.PHONY: $(shell grep -E '^[a-zA-Z_-]+:' Makefile | sed 's/:.*//')

The second form is a hack that makes everything phony — appropriate for pure task-runner Makefiles where no target is a real file. In build Makefiles with real file targets, declare .PHONY explicitly for each non-file target.


3. Recursive Variable Expansion (= vs :=) Causing Infinite Loops

Using = (recursive expansion) when reassigning a variable that references itself:

# INFINITE LOOP — do not do this
PATH = $(PATH):/usr/local/bin
CFLAGS = $(CFLAGS) -Wall

What happens: Make tries to expand $(PATH), which contains $(PATH), which contains $(PATH)... until Make crashes, hangs, or runs out of memory. With some versions of Make, you get a "Recursive variable references itself" error. With others, it just hangs.

Why: The = operator means "expand this variable every time it is referenced." Self-reference creates unbounded recursion. This is by design — recursive expansion is useful for variables that reference other variables defined later. But it is a trap for appending.

How to avoid: Use := (simply expanded) for any variable that references itself or needs to be evaluated once at assignment time:

PATH := $(PATH):/usr/local/bin
CFLAGS := $(CFLAGS) -Wall

# Or use += which handles this correctly regardless of the original flavor
CFLAGS += -Wall

Performance note: Recursive variables (=) are re-evaluated every time they are referenced. In large Makefiles with complex $(shell ...) calls inside recursive variables, this causes severe slowdowns as Make re-runs shell commands hundreds of times. Use := for anything involving $(shell ...).


4. Each Recipe Line Runs in a Separate Shell

This is the footgun that catches every experienced programmer who is new to Make:

deploy:
    cd /opt/myapp
    ./start.sh          # WRONG: this runs in the ORIGINAL directory

setup:
    export DB_HOST=postgres
    python migrate.py   # WRONG: DB_HOST is not set here

What happens: The cd runs in one shell process, then that shell exits. The ./start.sh runs in a brand-new shell in the original directory. Same with export — the variable is set in one shell, lost in the next.

Why: Make spawns a new /bin/sh process for each line of the recipe. Environment changes, directory changes, and shell variables do not persist between lines.

How to avoid: Chain commands with &&, use line continuations, or use .ONESHELL:

# Option 1: chain with && (preferred for short sequences)
deploy:
    cd /opt/myapp && ./start.sh

# Option 2: line continuation with backslash (for longer sequences)
deploy:
    cd /opt/myapp; \
    source .env; \
    ./start.sh

# Option 3: .ONESHELL (GNU Make 3.82+)
.ONESHELL:
deploy:
    cd /opt/myapp
    source .env
    ./start.sh

Warning: .ONESHELL applies globally to all targets in the Makefile, not just one. It changes error handling behavior — by default, only the last command's exit code matters. Combine with .SHELLFLAGS := -eu -o pipefail -c to fail on any command.


5. Forgetting @ Prefix (Noisy Command Echo)

Without @, Make prints each command before executing it. In a complex recipe, this floods the terminal with noise:

# Without @: prints the echo command AND its output
status:
    echo "Checking pods..."
    kubectl get pods
# Output:
#   echo "Checking pods..."
#   Checking pods...
#   kubectl get pods
#   NAME    READY   STATUS ...

What happens: Recipe output is cluttered with the commands themselves, making it hard to read actual output. In CI logs, this doubles the output length and makes failures harder to find.

How to avoid: Prefix echo/informational commands with @:

status:
    @echo "Checking pods..."
    kubectl get pods
# Output:
#   Checking pods...
#   NAME    READY   STATUS ...

Do not @-prefix commands where seeing the exact command is useful for debugging (deploy commands, build commands, curl calls). The command line itself is documentation of what ran. Reserve @ for echo statements and trivially obvious commands.

For suppressing all echo globally, use make -s (silent mode) or MAKEFLAGS += --silent — but this is usually too aggressive.


6. Variable Shadowing with Environment Variables

An environment variable with the same name as a Makefile variable silently overrides it:

TAG := v1.2.3

deploy:
    @echo "Deploying $(TAG)"
$ export TAG=broken-experiment
$ make deploy
Deploying broken-experiment   # NOT v1.2.3

What happens: By default, Make variables take precedence over environment variables. But command-line variables (make TAG=x) always override Makefile assignments. And with make -e, environment variables also take precedence. CI systems routinely set environment variables like CC, SHELL, PATH, HOME, USER, and TAG that collide with Makefile variables.

Why: Make's variable precedence order (highest to lowest): command-line > override directive > Makefile > environment. The -e flag swaps Makefile and environment precedence, which some CI systems enable by default.

How to avoid:

# Use override for variables that must not change from outside
override TAG := v1.2.3

# Use ?= for variables you WANT to be overridable from environment
REGISTRY ?= ghcr.io/myorg
ENV ?= dev

Best practice: ?= for configuration knobs (REGISTRY, ENV, KUBECONFIG), override for internal invariants. Avoid common environment variable names for internal variables — use prefixed names like APP_TAG instead of TAG.


7. make -j Race Conditions (Missing Prerequisites)

Parallel Make is powerful but exposes hidden dependency bugs that sequential runs mask:

build: compile link      # These look serial but...

compile:
    gcc -c src/*.c -o build/

link:
    gcc build/*.o -o bin/app
$ make build       # Works! (compile happens to run first by accident)
$ make -j4 build   # FAILS! link starts before compile finishes

What happens: With -j, Make runs compile and link simultaneously because link does not declare compile as a prerequisite. In the build: compile link rule, both are prerequisites of build, not of each other. Sequential runs work by accident of ordering; parallel runs expose the missing dependency.

Why: Make only respects explicitly declared dependencies. Without an explicit prerequisite declaration, targets are independent and eligible for parallel execution. The order in which prerequisites are listed does NOT guarantee execution order under -j.

How to avoid: Declare all real dependencies explicitly:

link: compile    # Now link waits for compile to finish
    gcc build/*.o -o bin/app

Test with make -j$(nproc) regularly, not just during release week. CI should always run parallel Make to catch these bugs early. The --output-sync=target flag (GNU Make 4.0+) prevents interleaved output from parallel jobs.


8. $$ for Literal Dollar Signs in Recipes

Make interprets $ as variable expansion. To pass a literal $ to the shell, you must escape it as $$:

# WRONG: Make expands $USER as a Make variable (probably empty)
show-user:
    echo "Hello $USER"
# Output: Hello

# CORRECT: $$ passes a literal $ to the shell
show-user:
    echo "Hello $$USER"
# Output: Hello jdoe

This affects every shell construct that uses $ — loops, awk, subshells, and variable references:

# WRONG — shell for loop
list-pods:
    for pod in $(kubectl get pods -o name); do echo $pod; done

# CORRECT
list-pods:
    for pod in $$(kubectl get pods -o name); do echo $$pod; done

# WRONG — awk field reference
parse:
    awk '{print $2}' data.txt

# CORRECT
parse:
    awk '{print $$2}' data.txt

# WRONG — subshell
info:
    echo "Running on $(hostname)"   # Make looks for variable "hostname)"

# CORRECT
info:
    echo "Running on $$(hostname)"

How to remember: if the $ is meant for the shell (shell variables, awk, subshells, for-loops), double it. If it is meant for Make ($(CC), $@, $<), single $. When in doubt, double it — a doubled $ in a Make context just produces a literal $, which usually fails loudly if wrong.


9. $(wildcard) Evaluated at Parse Time vs Shell Glob at Runtime

# $(wildcard) runs when the Makefile is PARSED — before any target executes
SOURCES := $(wildcard src/*.go)

# Shell glob runs when the RECIPE executes — after prerequisites are built
build:
    go build src/*.go

What happens: If files are generated by an earlier target and you use $(wildcard) to collect them, the wildcard runs before any target executes — so the generated files are not included. Your build silently compiles a subset of the source.

generate:
    protoc --go_out=src/ proto/*.proto

# BUG: $(wildcard) runs before 'generate', so protobuf .go files are missed
SOURCES := $(wildcard src/*.go)

build: generate
    go build $(SOURCES)    # Missing generated files!

Why: Make reads and evaluates the entire Makefile before running any recipe. $(wildcard), $(shell), and all other functions run during the parse phase, not during execution.

How to avoid: For files that exist before Make runs, $(wildcard) is fine. For generated files, use shell globs in recipes or secondary expansion:

# Option 1: use shell glob in the recipe (simplest)
build: generate
    go build $$(find src/ -name '*.go')

# Option 2: use secondary expansion (advanced)
.SECONDEXPANSION:
build: generate $$(wildcard src/*.go)
    go build $^

10. .DELETE_ON_ERROR Not Set (Partial Output Files Survive Failure)

Make's default behavior when a recipe fails: the partially-written target file is kept. On the next run, Make sees the file exists and skips the target.

# Without .DELETE_ON_ERROR:
report.pdf: data.csv
    python generate_report.py data.csv > report.pdf
# If generate_report.py crashes halfway through, a partial report.pdf exists.
# Next "make report.pdf" says "up to date" — you ship a broken report.

What happens: Corrupt or incomplete output files survive failed builds. Subsequent runs see the timestamp and say "up to date." You deploy a half-generated config, a truncated binary, or a partial report without knowing.

Why: Historical default from the 1970s. Some recipes produce valid partial output (append-mode logs). But for virtually all modern use cases, a partial output is garbage that should not exist.

How to avoid: Add this to the top of every Makefile, unconditionally:

.DELETE_ON_ERROR:

This tells Make to delete the target file if any recipe command exits with a non-zero status. It should be the default, but it is not and never will be for backwards compatibility. Every Makefile you write should include this.


11. include with Missing File Behavior

include config.mk    # Fatal error if config.mk does not exist
-include config.mk   # SILENT skip if config.mk does not exist

What happens with include: Make errors out if the file is missing — that is usually what you want for required configuration.

What happens with -include: Make silently continues, and all variables and targets from that file are undefined. Your build proceeds with empty variables and wrong defaults. You get cryptic failures far downstream instead of a clear "file not found" at the top.

-include deploy-config.mk   # Accidentally deleted

deploy:
    kubectl apply -f $(MANIFEST_DIR)/   # MANIFEST_DIR is empty — applies nothing

Why: -include was designed for optional overrides (user-local config, machine-specific flags). But if a required config file is accidentally deleted or the path is wrong, -include masks the problem completely.

How to avoid: Use include (not -include) for files that must exist. Reserve -include only for genuinely optional files:

# Required — fatal if missing
include mk/docker.mk
include mk/terraform.mk
include mk/version.mk

# Optional — user-local overrides, safe to skip
-include local.mk
-include .env.mk

12. Not Quoting Paths with Spaces in Recipes

Make and the shell both split on whitespace. A variable containing a path with spaces becomes multiple arguments:

DATA_DIR := /mnt/shared data/project

process:
    python analyze.py $(DATA_DIR)/input.csv
# Expands to: python analyze.py /mnt/shared data/project/input.csv
# Shell sees TWO arguments: "/mnt/shared" and "data/project/input.csv"

What happens: Commands receive wrong arguments. rm -rf $(BUILD_DIR) with a space in the path could delete the wrong directories. cp copies to the wrong place. Every command that takes file arguments is affected.

Worse case: Variable is empty. rm -rf $(BUILD_DIR)/ becomes rm -rf / if BUILD_DIR is unset and your shell does not have safeguards.

How to avoid: Best practice is to never use spaces in paths. If you cannot control the paths:

process:
    python analyze.py "$(DATA_DIR)/input.csv"

# For rm, always use a guard
clean:
    @[ -n "$(BUILD_DIR)" ] && rm -rf "$(BUILD_DIR)" || echo "BUILD_DIR is empty, refusing to rm"

13. Silently Using Wrong Make (BSD vs GNU Differences)

macOS ships with BSD Make (bmake). Linux ships with GNU Make. They are not compatible. A Makefile that works on your Mac may break in CI (Linux), and vice versa.

Key incompatibilities:

Feature GNU Make BSD Make
.ONESHELL Supported (3.82+) Not supported
$(shell ...) Supported Not supported (use !=)
override directive Supported Limited support
.SHELLFLAGS Supported Not supported
$(eval ...) Supported Not supported
Conditional syntax ifeq/ifneq/ifdef .if/.else/.endif
Include syntax include / -include .include / .sinclude
Pattern rules % Supported Limited/different
MAKEFLAGS format Long options Single letters
# Works on GNU Make, breaks on BSD Make
.ONESHELL:
SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c

VERSION := $(shell git describe --tags)

How to avoid: If your team uses macOS, install GNU Make via Homebrew (brew install make) and use gmake. Add a guard at the top of your Makefile:

ifeq (,$(filter GNU,$(shell $(MAKE) --version 2>/dev/null)))
$(error This Makefile requires GNU Make. Install it with: brew install make)
endif

Or simply document in your README: "Requires GNU Make 4.0+."


14. Pattern Rule Prerequisite Ordering Matters

In pattern rules, the first prerequisite has special meaning — it is what $< (the automatic variable for "first prerequisite") expands to:

# $< is src/%.c here — correct, we compile the .c file
%.o: %.c %.h
    $(CC) -c $< -o $@

# WRONG ORDER: $< is now %.h — you are compiling the header file!
%.o: %.h %.c
    $(CC) -c $< -o $@

What happens: $< always refers to the first prerequisite. If you reorder prerequisites in a pattern rule without updating the recipe, you silently compile the wrong file, link the wrong object, or process the wrong input.

This also affects $^ (all prerequisites) and $? (prerequisites newer than target) — but $< is the dangerous one because it selects a single file.

Broader issue: Pattern rule matching priority. When multiple pattern rules can build the same target, Make uses the first one it finds with all prerequisites satisfied. Reordering include directives or moving rules between files can silently change which pattern rule applies.

# If both rules exist, which one builds foo.o?
%.o: %.c
    $(CC) -c $< -o $@

%.o: %.cpp
    $(CXX) -c $< -o $@

Make picks the first rule whose prerequisites exist. If both foo.c and foo.cpp exist, the first rule wins — silently. If you later reorder the rules, the build silently switches compilers.

How to avoid: Always put the primary source file first in prerequisite lists. Document pattern rules clearly. If you have competing patterns, use explicit rules for ambiguous targets or use static pattern rules to constrain the match:

# Static pattern rule — only applies to the listed targets
C_OBJS := foo.o bar.o
$(C_OBJS): %.o: %.c
    $(CC) -c $< -o $@

15. MAKEFLAGS Inheritance in Recursive Make

When a Makefile calls another Makefile using $(MAKE), the child inherits MAKEFLAGS from the parent — including -j (parallelism), -k (keep going), -s (silent), and any variable overrides:

# Parent Makefile
all:
    $(MAKE) -C lib/
    $(MAKE) -C app/

# Run with:
$ make -j8 all
# Each sub-make ALSO gets -j8, so you now have 8 + 8 + 8 = 24 parallel jobs

What happens: Parallelism multiplies with each level of recursive Make. A -j8 at the top level spawns sub-makes that each also run with -j8, overwhelming the system with processes. Builds become slower, not faster. On memory-constrained CI runners, this causes OOM kills.

Flag leakage: -k (keep going after errors) propagates too. A parent that uses -k for resilience passes it to children, which may mask critical build failures in subdirectories.

Variable override leakage: Command-line variable assignments propagate via MAKEFLAGS:

$ make CC=clang all
# Every sub-make also gets CC=clang, even if lib/ needs gcc

How to avoid:

# Option 1: use the jobserver (GNU Make does this automatically with $(MAKE))
# The -j flag is shared via a jobserver pipe, not duplicated.
# But ONLY if you use $(MAKE), not bare "make"
all:
    $(MAKE) -C lib/    # Correct: uses jobserver, shares the -j pool
    make -C app/       # WRONG: spawns independent make, -j is duplicated

# Option 2: strip flags in the child Makefile
# In lib/Makefile:
MAKEFLAGS :=    # Reset inherited flags

# Option 3: override -j for specific sub-makes
all:
    $(MAKE) -C vendor/ -j1    # Force serial for flaky vendor build

Critical rule: Always use $(MAKE) (never bare make) for recursive invocations. $(MAKE) enables the jobserver protocol that coordinates parallelism across sub-makes. Bare make creates independent processes that ignore the jobserver, duplicating parallelism and causing resource exhaustion.

Also beware: export in a parent Makefile sends variables to all child processes, including sub-makes. An export CFLAGS in the parent overrides the child's CFLAGS, leading to compilation with wrong flags and no warning.