Makefile Layered Dependencies: Avoiding Unnecessary Rebuilds
Makefile rebuild decisions use file timestamps. When a target is an output file, make compares the target's mtime against its prerequisites — skip if target is newer. When a target is a phony name with no output file, make always runs the recipe. Layered dependencies chain file targets to enable accurate change detection.
How make decides whether to rebuild
make compares timestamps: if a target file is newer than all its prerequisites, skip the recipe. If any prerequisite is newer, run the recipe.
# Correct: output file is the target
BUILD_DIR := bin
APP_NAME := myapp
GO_SOURCES := $(shell find . -name '*.go')
$(BUILD_DIR)/$(APP_NAME): $(GO_SOURCES)
@echo "Building..."
go build -o $(BUILD_DIR)/$(APP_NAME) cmd/main.go
make checks: is bin/myapp newer than all .go files? If yes, skip. If any .go file is newer, rebuild.
The layered dependency pattern
Chain an alias target to the file target to get both a convenient name and accurate rebuild detection:
# Layer 1: convenient alias
build: $(BUILD_DIR)/$(APP_NAME)
# Layer 2: file target with source dependencies
$(BUILD_DIR)/$(APP_NAME): $(GO_SOURCES)
@echo "Building $(APP_NAME)..."
go build -o $(BUILD_DIR)/$(APP_NAME) cmd/main.go
make build → checks build → has no file, checks prerequisites → checks $(BUILD_DIR)/$(APP_NAME) → compares timestamp to .go files → skips if up to date.
Running make build twice with no file changes outputs nothing the second time — the binary is current.
The flat dependency problem
# Wrong: build depends on source files directly, not the output file
build: $(GO_SOURCES)
go build -o $(BUILD_DIR)/$(APP_NAME) cmd/main.go
build is not a file — make has no output timestamp to compare. Every make build runs the recipe unconditionally, regardless of whether sources changed.
$ make build
Building... ← runs even though nothing changed
$ make build
Building... ← runs again
.PHONY prevents stale targets from masking build commands when a file named 'build' exists
GotchaBuild SystemsIf make finds a file named 'build' in the directory, it treats the 'build' target as up-to-date (since no prerequisites are listed, the file is always current). The recipe never runs. Declaring .PHONY: build tells make to always consider the target out-of-date, regardless of any file named 'build'.
Prerequisites
- Makefile basics
- File system timestamps
Key Points
- .PHONY: clean test build — declare all non-file targets as phony.
- A phony target always runs its recipe; a file target skips if the file is newer than prerequisites.
- Pattern rules (%.o: %.c) apply to all matching files — efficient for C/C++ compilation.
- $(MAKE) -j4 runs up to 4 targets in parallel; dependencies ensure correct ordering.
Practical Makefile patterns
.PHONY: build clean test lint
BUILD_DIR := bin
APP_NAME := myapp
GO_SOURCES := $(shell find . -name '*.go' -not -path './vendor/*')
# Layered: alias → file target → source files
build: $(BUILD_DIR)/$(APP_NAME)
$(BUILD_DIR)/$(APP_NAME): $(GO_SOURCES) | $(BUILD_DIR)
go build -o $@ ./cmd/main.go
# Order-only prerequisite: create dir if missing, but don't trigger rebuild
$(BUILD_DIR):
mkdir -p $@
# Pattern: $@ = target name, $< = first prerequisite, $^ = all prerequisites
test:
go test ./...
clean:
rm -rf $(BUILD_DIR)
lint: $(GO_SOURCES)
golangci-lint run
$@ expands to the target name. $< expands to the first prerequisite. | $(BUILD_DIR) is an order-only prerequisite — the directory is created if missing but its mtime doesn't trigger a rebuild.
A Makefile has `build: $(GO_SOURCES)` with a build recipe. You run `make build`. No source files changed. Does make rebuild?
easy'build' is not a file on disk. make checks if the target file exists and is newer than prerequisites.
ANo — make sees that source files haven't changed and skips the build
Incorrect.make uses file timestamps to determine staleness. 'build' is not a file, so make has no timestamp to compare against prerequisites. It always considers the target out-of-date.BYes — 'build' is not a file, so make has no mtime to compare. The recipe runs unconditionally every time.
Correct!When a target doesn't correspond to a file on disk, make cannot compare timestamps. It treats the target as always out-of-date and runs the recipe on every invocation. Fix options: (1) use a layered dependency where 'build' depends on the actual output file, so make can compare the binary against source mtimes; (2) declare 'build' as .PHONY: build to explicitly document the intentional always-rebuild behavior.CIt depends on whether $(GO_SOURCES) expands correctly
Incorrect.If $(GO_SOURCES) expands to nothing, make has no prerequisites and considers 'build' up-to-date (no newer prerequisites exist). But if sources are listed and 'build' is not a file, make still can't compare timestamps — it rebuilds.Dmake caches the previous build result and skips if the recipe didn't change
Incorrect.make has no recipe caching. It uses only file timestamps (target vs prerequisites). Tools like ccache or bazel provide content-addressed caching, but not standard make.
Hint:For make to skip a recipe, it needs to compare the target file's mtime against prerequisites. What happens when there's no target file?