Makefile Best Practices
Comprehensive guide to writing professional, maintainable, and efficient Makefiles.
Overview
Makefile Best Practices
Comprehensive guide to writing professional, maintainable, and efficient Makefiles.
Table of Contents
- Essential Special Targets
- File Organization
- Target Declarations
- Variable Management
- Recipe Best Practices
- Dependency Management
- Performance Optimization
- Portability
- Documentation
- Security
- Advanced Patterns
Modern Makefile Header (Recommended)
For modern, robust Makefiles, start with this recommended preamble from Jacob Davis-Hansson:
# Modern Makefile Header
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
Explanation:
| Setting | Purpose |
|---|---|
SHELL := bash | Use bash instead of /bin/sh for modern shell features |
.ONESHELL: | Run entire recipe in single shell (enables multi-line scripts) |
.SHELLFLAGS := -eu -o pipefail -c | Stop on errors (-e), undefined vars (-u), pipe failures |
.DELETE_ON_ERROR: | Delete target on recipe failure (prevents corrupt builds) |
--warn-undefined-variables | Alert on undefined Make variable references |
--no-builtin-rules | Disable built-in implicit rules for faster builds |
Note: This preamble is for GNU Make 4.0+. For maximum portability, use a simpler header.
Essential Special Targets
GNU Make provides several special targets that should be used in professional Makefiles.
.DELETE_ON_ERROR (Critical)
Always include .DELETE_ON_ERROR: at the top of your Makefile. This ensures partially built targets are deleted when a recipe fails, preventing corrupt builds.
# CRITICAL: Delete target on recipe failure
.DELETE_ON_ERROR:
# Rest of Makefile follows...
Why it matters:
- Without this, a failed build leaves a partial/corrupt file
- Next
makerun sees the file exists and skips rebuilding - Results in broken builds that are hard to debug
From GNU Make Manual: "This is almost always what you want make to do, but it is not historical practice; so for compatibility, you must explicitly request it."
Exception: Use .PRECIOUS to protect specific targets that should be preserved even on error:
.DELETE_ON_ERROR:
.PRECIOUS: expensive-to-rebuild.dat
.PHONY (Always Required)
Declare non-file targets as phony to avoid conflicts and improve performance:
.PHONY: all build clean test install
.ONESHELL (For Multi-line Recipes)
Run entire recipe in a single shell invocation:
.ONESHELL:
deploy:
set -e
echo "Deploying..."
cd /app
git pull
./restart.sh
Without .ONESHELL, each line runs in a separate shell, so cd has no effect on subsequent lines.
.SUFFIXES (For Performance)
Clear built-in suffix rules to speed up builds:
# Disable all built-in suffix rules
.SUFFIXES:
# Only keep rules you need (optional)
.SUFFIXES: .c .o
Why: GNU Make has ~90 built-in implicit rules. Clearing them speeds up rule resolution.
Complete Special Targets Header
# Modern Makefile Header
.DELETE_ON_ERROR:
.SUFFIXES:
.PHONY: all build clean test install deploy
# Your targets follow...
File Organization
Directory Structure
# Organized Makefile structure
.PHONY: all clean test install
# Variables section
PROJECT := myapp
VERSION := 1.0.0
BUILD_DIR := build
SRC_DIR := src
# Include external makefiles
include config.mk
include rules/*.mk
# Default target (should be first)
all: build test
# Build targets
build: $(BUILD_DIR)/$(PROJECT)
# ... more targets
Modular Organization
Use include for large projects:
# Main Makefile
include config/variables.mk
include rules/build.mk
include rules/test.mk
include rules/deploy.mk
.PHONY: all
all: build test
Namespace Targets
Use / as delimiter for namespaced targets:
# Good: Namespaced targets
.PHONY: docker/build docker/push docker/clean
docker/build:
docker build -t $(IMAGE) .
docker/push:
docker push $(IMAGE)
docker/clean:
docker rmi $(IMAGE)
# Avoid: Flat namespace
.PHONY: docker-build docker-push docker-clean
Target Declarations
Always Declare .PHONY
Declare targets that don't create files as phony:
# GOOD: Proper .PHONY declarations
.PHONY: all clean test install build deploy
all: build test
clean:
rm -rf $(BUILD_DIR)
test:
go test ./...
# BAD: Missing .PHONY - causes issues if files named 'clean' or 'test' exist
clean:
rm -rf build
test:
go test ./...
Organize .PHONY Declarations
# Group related phony targets
.PHONY: all build clean
.PHONY: test test-unit test-integration
.PHONY: install uninstall
.PHONY: docker/build docker/push docker/clean
# Or use a single declaration (mbake can organize this)
.PHONY: all build clean test test-unit test-integration install uninstall
Default Target
First target is the default (or use .DEFAULT_GOAL):
# Method 1: First target is default
.PHONY: all
all: build test
# Method 2: Explicit default goal
.DEFAULT_GOAL := build
.PHONY: build test
build:
go build -o app
test:
go test ./...
Variable Management
Variable Assignment Operators
Choose the right operator for your use case:
# Simple assignment (=) - Recursive expansion (evaluated when used)
CFLAGS = -Wall $(OPTIMIZE)
OPTIMIZE = -O2
# CFLAGS will expand to: -Wall -O2 (recursive)
# Immediate assignment (:=) - Expanded immediately (RECOMMENDED for most cases)
BUILD_TIME := $(shell date +%Y%m%d-%H%M%S)
VERSION := 1.0.0
# Evaluated once, avoids repeated shell calls
# Conditional assignment (?=) - Set only if not already defined
CC ?= gcc
PREFIX ?= /usr/local
# Allows environment variable override
# Append (+=) - Add to existing value
CFLAGS := -Wall
CFLAGS += -Wextra
CFLAGS += -O2
# CFLAGS = -Wall -Wextra -O2
Use := for Most Variables
# GOOD: Immediate expansion (predictable, faster)
BUILD_DIR := build
SRC_FILES := $(wildcard src/*.c)
TIMESTAMP := $(shell date +%s)
# AVOID: Recursive expansion (unpredictable, slower)
BUILD_DIR = build
SRC_FILES = $(wildcard src/*.c) # Re-evaluated every time!
TIMESTAMP = $(shell date +%s) # Shell called multiple times!
Sane Defaults with ?=
# Allow user/environment override
CC ?= gcc
CXX ?= g++
PREFIX ?= /usr/local
DESTDIR ?=
VERBOSE ?= 0
# Usage:
# make # Uses defaults
# make CC=clang # Override CC
# PREFIX=/opt make # Override via environment
Variable Naming
# GOOD: Clear, consistent naming
PROJECT_NAME := myapp
BUILD_DIR := build
SOURCE_FILES := $(wildcard src/*.c)
COMPILER_FLAGS := -Wall -Wextra -O2
# AVOID: Unclear abbreviations
PROJ := myapp
BDIR := build
SRCS := $(wildcard src/*.c)
FLAGS := -Wall
Recipe Best Practices
Use Tabs, Not Spaces
# GOOD: Tab character (required)
build:
@echo "Building..."
go build -o app
# BAD: Spaces (will fail)
build:
@echo "Building..."
go build -o app
Note: Makefiles require TAB characters for recipes. Configure your editor to use tabs for Makefiles.
Error Handling
# Method 1: Prefix with @ to suppress echo, - to ignore errors
clean:
@echo "Cleaning build artifacts..."
-rm -rf $(BUILD_DIR)
@echo "Done!"
# Method 2: Use || for conditional error handling
build:
mkdir -p $(BUILD_DIR) || exit 1
go build -o $(BUILD_DIR)/app || exit 1
# Method 3: Use set -e for strict error handling
test:
@set -e; \\
echo "Running tests..."; \\
go test ./...; \\
echo "All tests passed!"
# Method 4: Check exit codes explicitly
deploy:
@./scripts/deploy.sh
@if [ $$? -ne 0 ]; then \\
echo "Deployment failed!"; \\
exit 1; \\
fi
Multi-line Recipes
# Use backslash for line continuation
build: $(SOURCES)
@echo "Building $(PROJECT)..."; \\
mkdir -p $(BUILD_DIR); \\
$(CC) $(CFLAGS) -o $(BUILD_DIR)/$(PROJECT) $(SOURCES); \\
echo "Build complete!"
# Or use .ONESHELL for easier multi-line scripts
.ONESHELL:
test:
echo "Running tests..."
for file in tests/*.sh; do
bash $$file
done
echo "All tests passed!"
Silent vs Verbose Output
# Use @ to suppress command echo
.PHONY: build
build:
@echo "Building..."
@$(CC) $(CFLAGS) -o app $(SOURCES)
# Optional verbose mode
VERBOSE ?= 0
ifeq ($(VERBOSE),1)
Q :=
else
Q := @
endif
build:
$(Q)echo "Building..."
$(Q)$(CC) $(CFLAGS) -o app $(SOURCES)
# Usage:
# make build # Silent
# make build VERBOSE=1 # Verbose
Dependency Management
Specify Dependencies Correctly
# GOOD: Proper dependency chain
app: $(OBJECTS)
$(CC) -o $@ $^
%.o: %.c %.h
$(CC) $(CFLAGS) -c $< -o $@
# BAD: Missing dependencies - app won't rebuild when headers change
app: $(OBJECTS)
$(CC) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
Auto-generate Dependencies (C/C++)
# Automatic dependency generation
DEPDIR := .deps
DEPFLAGS = -MT $@ -MMD -MP -MF $(DEPDIR)/$*.d
%.o: %.c $(DEPDIR)/%.d | $(DEPDIR)
$(CC) $(DEPFLAGS) $(CFLAGS) -c $< -o $@
$(DEPDIR):
@mkdir -p $@
# Include generated dependency files
-include $(patsubst %,$(DEPDIR)/%.d,$(basename $(SOURCES)))
Order-Only Prerequisites
Use | for prerequisites that shouldn't trigger rebuilds:
# Regular prerequisites trigger rebuild
$(BUILD_DIR)/app: $(SOURCES)
$(CC) -o $@ $^
# Order-only prerequisites (directories) don't trigger rebuild
$(BUILD_DIR)/app: $(SOURCES) | $(BUILD_DIR)
$(CC) -o $@ $^
$(BUILD_DIR):
mkdir -p $@
# Without |, updating BUILD_DIR timestamp would trigger app rebuild
# With |, app only rebuilds when SOURCES change
VPATH for Source Organization
# Search for prerequisites in multiple directories
VPATH = src:include:tests
# Or use vpath for specific patterns
vpath %.c src
vpath %.h include
vpath %.test tests
# Now Make will find files in these directories
app: main.o utils.o
$(CC) -o $@ $^
# Make will find src/main.c and src/utils.c automatically
Performance Optimization
Use .PHONY for Performance
# GOOD: Phony targets skip implicit rule search
.PHONY: clean test install
clean:
rm -rf $(BUILD_DIR)
# BAD: Without .PHONY, Make checks for file existence
clean:
rm -rf $(BUILD_DIR)
Parallel Builds
# Enable parallel builds (use -j flag)
# make -j8 build # 8 parallel jobs
# For sequential targets, use .NOTPARALLEL
.NOTPARALLEL: deploy
deploy: build test
./scripts/deploy.sh
# Or use order-only prerequisites for partial ordering
build-frontend: | build-backend
npm run build
Intermediate File Cleanup
# Mark intermediate files for auto-deletion
.INTERMEDIATE: $(OBJECTS)
# Or mark files to keep through one build
.SECONDARY: $(OBJECTS)
# Delete on error (recommended)
.DELETE_ON_ERROR:
# Example: .o files cleaned after linking
app: main.o utils.o
$(CC) -o $@ $^
# main.o and utils.o auto-deleted after successful build
Avoid Redundant Shell Calls
# BAD: Shell called every time variable is used
DATE = $(shell date +%Y%m%d)
VERSION = $(shell git describe --tags)
target1:
echo $(DATE) # Shell called here
target2:
echo $(DATE) # Shell called again!
# GOOD: Use := for one-time evaluation
DATE := $(shell date +%Y%m%d)
VERSION := $(shell git describe --tags)
target1:
echo $(DATE) # Expands to cached value
target2:
echo $(DATE) # Same cached value
Portability
POSIX Shell Compatibility
# GOOD: POSIX-compatible commands
.PHONY: install
install:
mkdir -p $(DESTDIR)$(PREFIX)/bin
cp -f app $(DESTDIR)$(PREFIX)/bin/
chmod 755 $(DESTDIR)$(PREFIX)/bin/app
# AVOID: Bashisms or GNU-specific features
install:
mkdir -p $(DESTDIR)$(PREFIX)/bin
cp app $(DESTDIR)$(PREFIX)/bin/ # Missing -f for portability
Cross-Platform Variables
# Detect operating system
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
PLATFORM := linux
EXE_EXT :=
endif
ifeq ($(UNAME_S),Darwin)
PLATFORM := macos
EXE_EXT :=
endif
ifeq ($(OS),Windows_NT)
PLATFORM := windows
EXE_EXT := .exe
endif
# Use platform-specific settings
APP := app$(EXE_EXT)
Avoid Hard-Coded Paths
# BAD: Hard-coded paths
install:
cp app /usr/local/bin/
cp docs/app.1 /usr/share/man/man1/
# GOOD: Use variables for paths
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
MANDIR ?= $(PREFIX)/share/man
install:
install -d $(DESTDIR)$(BINDIR)
install -m 755 app $(DESTDIR)$(BINDIR)/
install -d $(DESTDIR)$(MANDIR)/man1
install -m 644 docs/app.1 $(DESTDIR)$(MANDIR)/man1/
Documentation
Comment Your Makefiles
# Project: MyApp
# Description: Build system for MyApp project
# Author: Your Name
# Version: 1.0.0
# Configuration variables
PROJECT := myapp
VERSION := $(shell git describe --tags 2>/dev/null || echo "dev")
# Build directories
BUILD_DIR := build
SRC_DIR := src
# Compiler settings
CC := gcc
CFLAGS := -Wall -Wextra -O2
# Default target: Build and test the application
.PHONY: all
all: build test
# Build the main application binary
.PHONY: build
build: $(BUILD_DIR)/$(PROJECT)
@echo "Build complete: $(BUILD_DIR)/$(PROJECT)"
# Run all test suites
.PHONY: test
test:
@echo "Running tests..."
@./scripts/run-tests.sh
Help Target
# Provide a help target
.PHONY: help
help:
@echo "Available targets:"
@echo " make build - Build the application"
@echo " make test - Run tests"
@echo " make clean - Remove build artifacts"
@echo " make install - Install to $(PREFIX)"
@echo ""
@echo "Variables:"
@echo " PREFIX=$(PREFIX)"
@echo " CC=$(CC)"
# Or auto-generate from comments
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \\
awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\
", $$1, $$2}'
build: ## Build the application
@go build -o app
test: ## Run all tests
@go test ./...
clean: ## Remove build artifacts
@rm -rf $(BUILD_DIR)
Security
Avoid Hardcoded Credentials
# BAD: Hardcoded secrets
deploy:
curl -H "Authorization: Bearer sk-1234567890" https://api.example.com/deploy
# GOOD: Use environment variables
deploy:
@if [ -z "$$API_TOKEN" ]; then \\
echo "Error: API_TOKEN not set"; \\
exit 1; \\
fi
curl -H "Authorization: Bearer $$API_TOKEN" https://api.example.com/deploy
Validate Input Variables
# Validate critical variables
.PHONY: deploy
deploy:
@if [ -z "$(ENV)" ]; then \\
echo "Error: ENV not specified (prod|staging|dev)"; \\
exit 1; \\
fi
@if [ "$(ENV)" != "prod" ] && [ "$(ENV)" != "staging" ] && [ "$(ENV