Skip to main content
โšก Calmops

Makefile Guide: From Simple Templates to Real-World Patterns

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 name
  • dependencies โ€” files that must exist/be up-to-date before building the target
  • command โ€” 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)

Resources

Comments