Introduction
make is a build automation tool that determines which parts of a program need to be recompiled and executes the necessary commands. While originally designed for C/C++ projects, Makefiles are widely used as task runners for any language โ Python, Go, JavaScript, and more.
Basic Makefile Structure
A Makefile consists of rules:
target: dependencies
command
command
targetโ the file to build, or a phony task namedependenciesโ files that must exist/be up-to-date before building the targetcommandโ shell commands to run (must be indented with a tab, not spaces)
Simple C Project Template
# Compiler and flags
CC = gcc
CFLAGS = -std=c11 -Wall -Wextra -fmax-errors=10
LFLAGS =
LIBS =
# Source and output
OBJFILES = program.o weatherstats.o
MAIN = program
# Default target
all: $(MAIN)
# Link object files into executable
$(MAIN): $(OBJFILES)
$(CC) $(CFLAGS) -o $(MAIN) $(OBJFILES) $(LFLAGS) $(LIBS)
# Compile each .c file to .o
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
# Remove build artifacts
clean:
rm -rf $(OBJFILES) $(MAIN)
# Build and run
launch: $(MAIN)
./$(MAIN)
.PHONY: all clean launch
Run it:
make # builds the project (runs 'all' target)
make clean # removes build artifacts
make launch # builds and runs
Automatic Variables
Make provides special variables inside rules:
| Variable | Meaning |
|---|---|
$@ |
The target name |
$< |
The first dependency |
$^ |
All dependencies |
$* |
The stem (matched by %) |
$(@D) |
Directory part of target |
$(@F) |
File part of target |
# $@ = output file, $< = first input file
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
# $^ = all dependencies
program: main.o utils.o parser.o
$(CC) -o $@ $^
Phony Targets
.PHONY declares targets that are not files โ they always run regardless of whether a file with that name exists:
.PHONY: all clean test install help
all: build
clean:
rm -rf build/ dist/ *.o
test:
pytest tests/
install:
pip install -e .
help:
@echo "Available targets:"
@echo " all - Build the project"
@echo " clean - Remove build artifacts"
@echo " test - Run tests"
@echo " install - Install in development mode"
Variables and Substitution
# Simple assignment
CC = gcc
# Immediate assignment (evaluated once)
BUILD_DATE := $(shell date +%Y-%m-%d)
# Conditional assignment (only if not already set)
PREFIX ?= /usr/local
# Append to variable
CFLAGS += -DDEBUG
# String substitution
SOURCES = main.c utils.c parser.c
OBJECTS = $(SOURCES:.c=.o) # => main.o utils.o parser.o
# Pattern substitution
OBJECTS = $(patsubst %.c,%.o,$(SOURCES)) # same result
Makefile as a Task Runner (Any Language)
Makefiles work great as task runners for Python, Go, Node.js, and other projects:
Python Project
PYTHON = python3
VENV = .venv
PIP = $(VENV)/bin/pip
PYTEST = $(VENV)/bin/pytest
RUFF = $(VENV)/bin/ruff
.PHONY: setup test lint format clean run
setup:
$(PYTHON) -m venv $(VENV)
$(PIP) install -r requirements.txt
test:
$(PYTEST) tests/ -v
lint:
$(RUFF) check src/
format:
$(RUFF) format src/
clean:
find . -type d -name __pycache__ -exec rm -rf {} +
find . -name "*.pyc" -delete
rm -rf .pytest_cache .ruff_cache
run:
$(PYTHON) src/main.py
Go Project
BINARY = myapp
VERSION = $(shell git describe --tags --always)
LDFLAGS = -ldflags "-X main.Version=$(VERSION)"
.PHONY: build test lint clean run docker
build:
go build $(LDFLAGS) -o bin/$(BINARY) ./cmd/$(BINARY)
test:
go test ./... -v -race
lint:
golangci-lint run
clean:
rm -rf bin/
run:
go run ./cmd/$(BINARY)
docker:
docker build -t $(BINARY):$(VERSION) .
Node.js / Frontend Project
NPM = npm
NODE_ENV ?= development
.PHONY: install dev build test lint clean
install:
$(NPM) install
dev:
$(NPM) run dev
build:
NODE_ENV=production $(NPM) run build
test:
$(NPM) test -- --run
lint:
$(NPM) run lint
clean:
rm -rf node_modules dist .cache
Conditional Logic
# OS detection
UNAME := $(shell uname)
ifeq ($(UNAME), Darwin)
OPEN = open
else
OPEN = xdg-open
endif
# Debug vs Release builds
DEBUG ?= 0
ifeq ($(DEBUG), 1)
CFLAGS += -g -DDEBUG
BUILD_DIR = build/debug
else
CFLAGS += -O2
BUILD_DIR = build/release
endif
Including Other Makefiles
# Split large Makefiles into smaller files
include config.mk
include rules.mk
# Include generated dependency files (common in C projects)
-include $(OBJECTS:.o=.d) # the - prefix ignores errors if files don't exist
Parallel Builds
# Build with 4 parallel jobs
make -j4
# Use all available CPU cores
make -j$(nproc)
Useful Patterns
Automatic Dependency Generation (C)
# Generate .d files that track header dependencies
DEPFLAGS = -MMD -MP
CFLAGS += $(DEPFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
# Include generated dependency files
-include $(OBJECTS:.o=.d)
Versioned Builds
VERSION = $(shell git describe --tags --always --dirty)
BUILD = $(shell git rev-parse --short HEAD)
DATE = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS = -X main.Version=$(VERSION) \
-X main.Build=$(BUILD) \
-X main.Date=$(DATE)
Colored Output
RED = \033[0;31m
GREEN = \033[0;32m
YELLOW = \033[0;33m
NC = \033[0m # No Color
build:
@echo "$(GREEN)[BUILD]$(NC) Building $(BINARY)..."
go build -o bin/$(BINARY) .
@echo "$(GREEN)[OK]$(NC) Build complete."
test:
@echo "$(YELLOW)[TEST]$(NC) Running tests..."
go test ./...
Common Pitfalls
Tabs vs Spaces
Commands in rules must use tabs, not spaces. Most editors can be configured to show whitespace:
target:
command # TAB here โ not spaces!
Missing .PHONY
Without .PHONY, if a file named clean exists, make clean won’t run:
touch clean
make clean # does nothing without .PHONY!
Variable Expansion Timing
# Lazy (re-evaluated each use)
VAR = $(shell date)
# Immediate (evaluated once at parse time)
VAR := $(shell date)
Comments