Make#

make is the original build DSL. A Makefile lists targets with their prerequisites and recipes; make figures out what to rebuild based on file timestamps. Created in 1976; standardized by POSIX; ubiquitous.

Make is brittle when used as a build system for large polyglot projects. It’s still the best lightweight task runner in existence, which is how most modern projects use it.

The Building Block#

target: prerequisite1 prerequisite2
<TAB>recipe-line-1
<TAB>recipe-line-2
  • target, the file produced (or a “phony” task name).

  • prerequisites, files / targets that must exist (or be up-to-date) first.

  • recipe, the shell commands to build the target.

Each recipe line must start with a TAB, not spaces. The single most common Make beginner trap.

A Minimal Build#

CC      ?= cc
CFLAGS  ?= -O2 -Wall -Wextra
LDFLAGS ?=
OBJS    := main.o util.o

app: $(OBJS)
<TAB>$(CC) $(LDFLAGS) -o $@ $^

%.o: %.c
<TAB>$(CC) $(CFLAGS) -c $< -o $@

clean:
<TAB>rm -f app $(OBJS)

.PHONY: clean
  • $@, the target.

  • $<, the first prerequisite.

  • $^, all prerequisites.

  • %, pattern; %.o: %.c says “any .o depends on the matching .c”.

Phony Targets (Make as Task Runner)#

When the “target” isn’t actually a file, mark it .PHONY:

.PHONY: build test fmt lint clean run

build:
<TAB>go build ./...

test:
<TAB>go test ./...

fmt:
<TAB>gofmt -w .

lint:
<TAB>golangci-lint run

run: build
<TAB>./app

clean:
<TAB>rm -rf bin/

This pattern (“Make as a project’s CLI”) is the dominant 2026 use.

Variables#

Make has several assignment operators with different expansion semantics. = expands lazily on every use; := expands once at definition time; ?= defaults without overriding; += appends. Picking the right form prevents the most common Makefile bugs.

Form

Behavior

=

recursively expanded; expands every reference

:=

simply expanded; expands once when defined

?=

set only if not already

+=

append

override =

win over command-line / env definitions

# Simple expansion (preferred when expansion order matters)
GO := $(shell command -v go)

# Conditional default
PORT ?= 8080

# Append to a list
FLAGS  := -O2
FLAGS  += -Wall

Automatic Variables#

Inside a recipe, Make exposes a small set of automatic variables for the target, prerequisites, and pattern stem. Using them correctly is what turns a Makefile from “list every file by hand” into a small declarative description that generalizes across hundreds of files.

Variable

Meaning

$@

target name

$<

first prerequisite

$^

all prerequisites (deduplicated)

$+

all prerequisites (with duplicates)

$*

the stem of a pattern match

$(@D)

directory part of $@

$(@F)

file part of $@

Functions#

GNU Make is a real (if uncomfortable) programming language.

SOURCES := $(wildcard src/*.c)
OBJECTS := $(patsubst src/%.c, build/%.o, $(SOURCES))

ifeq ($(OS),Windows_NT)
  EXE := .exe
else
  EXE :=
endif

define banner
============== $(1) ==============
endef

release:
<TAB>@$(call banner,Building release)
<TAB>$(MAKE) build CFLAGS="-O3"

The DSL has foreach, call, shell, filter, patsubst, addprefix, addsuffix, conditionals, and includes. The syntax is ugly; the power is real.

Common Patterns#

Help target, self-documenting Makefile.

.PHONY: help
help:
<TAB>@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
<TAB><TAB>awk 'BEGIN { FS = ":.*?## " } { printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2 }'

build: ## Compile the binary
<TAB>go build -o bin/app ./cmd/app

test: ## Run all tests
<TAB>go test ./...

Tool versions / dependencies:

GO_VERSION := $(shell go version | cut -d ' ' -f 3)

Watch / re-run:

.PHONY: watch
watch:
<TAB>find . -name '*.go' | entr -r $(MAKE) test

Variants#

The Make implementations an operator may meet across systems. GNU Make is the de-facto standard with the richest features; BSD Make has different conditional syntax; NMake is Windows-only; bmake is the portable BSD variant. Targeting GNU Make is safe for most new Makefiles.

  • GNU make, the de-facto standard; rich features, --jobs, pattern rules, include.

  • BSD make, on FreeBSD / NetBSD / OpenBSD; different syntax for conditionals.

  • NMake, Microsoft’s; Windows-only.

  • bmake, portable BSD make.

For new Makefile``s, target GNU make and call it ``Makefile; target BSD-portable Make explicitly when needed.

Modern Alternatives#

The 2026 landscape of task runners and build systems. Just is the friendliest replacement for “Make as task runner”; Task is the YAML alternative; Bazel and Buck2 are hermetic build systems for monorepos; CMake and Ninja drive C/C++ builds; language-native runners cover their own ecosystems.

  • Just, task runner; not a build system; no tabs, sane variables. Increasingly the default for “Make as task runner”.

  • Task, YAML task runner.

  • Bazel, Buck2 – hermetic build systems for huge polyglot codebases.

  • CMake, the C / C++ portable build generator (still emits Makefiles or Ninja).

  • Ninja, low-level, very fast builder; generated by CMake / Meson / Bazel rather than written by hand.

  • Mage, Make-replacement in Go.

  • npm scripts, cargo, mix, mvn, language-native task runners.

For new task-runner needs, Just is often the right choice. For build systems beyond a single language, Bazel or CMake.

When to Use Make#

The kinds of project where Make is still the right tool. Lightweight project CLIs, moderate-size C/C++ codebases, and the universal “every project has a make build” convention all play to Make’s strengths. Polyglot monorepos and hermetic builds want a real build system instead.

  • As a project’s lightweight CLI: make test, make fmt, make build.

  • For C / C++ codebases of moderate size.

  • Anywhere you want a “every project has a make build” convention.

When not to.

  • Polyglot monorepos, look at Bazel / Buck2 / Nx / Turbo.

  • Anything that needs hermeticity or remote caching.

  • When the scripts are getting long; switch to Just or shell scripts.

Pitfalls#

The traps that catch every Make user. Tabs versus spaces is the famous one; recursive versus simple expansion is the silent one; per-line subshells produce surprising state loss; -j parallelism reveals missing prerequisites; and shell portability across platforms takes care.

  • Tabs, not spaces, the rule that breaks every newcomer.

  • ``=`` vs. ``:=``, recursive vs. simple expansion; misuse surprises everywhere.

  • Implicit rules, %.o: %.c is built in but easy to override by accident.

  • Subshell per recipe line, each line runs in its own shell unless you use \ continuations or .ONESHELL:.

  • Parallelism, make -j exposes missing dependencies between targets. Add real prerequisites; don’t paper over with serial runs.

  • Cross-platform, shell commands on Linux differ from those on macOS / Windows. Pin a portable shell or warn users.