Overview

Every project I've joined in the last five years has had a Makefile, and half of them were wrong. They were either a thin wrapper around npm run that added nothing, or a graveyard of targets from three developers ago that nobody dared delete. Neither is what Make is for.

Make is a task runner with a specific model: files with dependencies, rebuilt when a dependency changes. Once you use it that way, it's the right tool for a specific set of jobs and the wrong tool for a lot of others. This is what I've landed on.

What Make actually does

The core model is: target: dependencies followed by a command. If the target doesn't exist, or any dependency is newer than the target, run the command.

build/app: src/main.go
	go build -o build/app ./src

Run make build/app. If build/app is newer than src/main.go, Make prints "up to date" and does nothing. If src/main.go is newer, it rebuilds. That's the whole feature, and it's genuinely useful for compiled languages, generated files, and build artifacts.

Where Make gets misused is when people use it as a generic script runner with no file dependencies at all:

.PHONY: test
test:
	npm test

This is a Makefile as a list of aliases. It works, and I've written it many times, but it's worth knowing you're not using Make's actual feature. When every target is .PHONY, you're getting no more than npm scripts or a shell script with extra syntax.

Where Make genuinely earns its place

Use caseWhy Make fits
Compiled languages (Go, C, Rust)Incremental rebuilds for free
Generated files (protobuf, codegen)Only regenerate when source changes
Multi-language monoreposOne entry point for every toolchain
Documentation buildsOnly rebuild changed pages
Asset pipelinesImage processing, minification

For pure JavaScript projects, npm scripts or pnpm is usually enough. Make's advantage shows up when you have a Go service, a Python CLI, and a React app in the same repo, and you want one command that builds all three in the right order.

The three things you need to know

Tabs, not spaces

Recipe lines must start with a literal tab character. Not four spaces. Not a tab that your editor converted to spaces. A tab. This has been the #1 source of Makefile frustration since 1976, and it's not going away.

build:
	echo "this works"
        echo "this fails with 'missing separator'"

Configure your editor to show whitespace and refuse to expand tabs in Makefiles. It saves a lot of confusion.

.PHONY

Declare targets that aren't files:

.PHONY: build test clean all

build:
	go build -o bin/app ./cmd/app

test:
	go test ./...

clean:
	rm -rf bin/

all: build test

Without .PHONY, a file named test in your project would make make test print "up to date" and do nothing. Declaring it phony tells Make to always run the recipe.

Variables and := vs =

APP_NAME = myapp         # recursive: evaluated when used
VERSION := 1.0.0         # simple: evaluated immediately
BINARY := bin/$(APP_NAME)

build:
	go build -o $(BINARY) -ldflags "-X main.version=$(VERSION)" ./cmd/app

:= is almost always what you want. = defers evaluation, which produces confusing results when a variable is defined later in the file.

A Makefile that works for a real project

# Detect the OS and use the right shell for each
SHELL := /bin/bash

# Project metadata
APP_NAME := myapp
VERSION  := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT   := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")

# Directories
BUILD_DIR   := build
BINARY      := $(BUILD_DIR)/$(APP_NAME)
GO_SOURCES  := $(shell find . -name '*.go' -not -path './vendor/*')

# Build flags
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT)
GOFLAGS := -trimpath

.PHONY: all build test lint clean fmt vet run docker

all: lint test build

# Real file target — incremental by default
$(BINARY): $(GO_SOURCES) go.mod go.sum
	@mkdir -p $(BUILD_DIR)
	go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $@ ./cmd/$(APP_NAME)

build: $(BINARY)

test:
	go test -race -coverprofile=coverage.out ./...
	go tool cover -func=coverage.out | tail -1

lint:
	golangci-lint run ./...

fmt:
	gofmt -s -w $(GO_SOURCES)

vet:
	go vet ./...

run: build
	./$(BINARY)

docker:
	docker build -t $(APP_NAME):$(VERSION) .

clean:
	rm -rf $(BUILD_DIR) coverage.out

Points worth calling out:

  • The binary target depends on the actual source files. Touch a Go file, run make, it rebuilds. Touch nothing, run make, it prints "up to date."
  • The version is baked in from Git. Every build knows its commit and tag, which makes debugging production issues much easier.
  • $@ is the target name. Instead of retyping $(BINARY), use $@. Same value, less repetition.
  • @ prefix silences the command echo. Use it for commands whose output you don't care about, like mkdir.

Parallel execution

make -j4

Runs up to four targets in parallel. This only works if the targets have no hidden dependencies — if test and build both write to the same directory, running them in parallel corrupts state.

For a build pipeline, it's transformative. A build that takes 90 seconds sequentially runs in 25 seconds with -j4. For anything else, avoid it.

Wildcard patterns

# Compile every .go file matching a pattern
build/%.pb.go: proto/%.proto
	protoc --go_out=build --go-grpc_out=build $<

# $< is the first dependency, $@ is the target

Pattern rules are what make Make genuinely different from a shell script. A single pattern rule handles every file matching the shape, and Make figures out the dependency graph automatically.

I use this for protobuf compilation. Adding a new .proto file requires zero Makefile changes — the pattern matches it.

Error handling

By default, Make stops at the first failing command in a recipe. To continue:

lint:
	golangci-lint run ./... || true

To run a cleanup regardless of success or failure:

.ONESHELL:
test:
	set -e
	go test ./... || (echo "tests failed, cleaning up" && rm -f /tmp/test-artifacts; exit 1)

.ONESHELL makes each recipe run in a single shell invocation, which lets multi-line logic share state. Without it, each line is a separate shell, and variables don't persist.

When not to use Make

  • Pure JavaScript project with no generated files. npm scripts or pnpm is simpler and has better cross-platform behavior.
  • Anything that needs to run identically on Windows. Make on Windows is possible but painful. Use just or task instead.
  • Complex build logic with conditionals on many variables. Make's conditionals are awkward. A shell script with case statements is clearer.
  • When you just want a list of aliases. If every target is .PHONY and none depend on files, use something purpose-built.

That last point is the one I'd emphasize. A Makefile that only contains phony targets is a Makefile in name only. It works, but you're paying the tab-vs-space cost and the $@/$< syntax cost for none of the incremental build benefit.

The rule I use

If a target produces a file that other targets depend on, it belongs in a Makefile with proper dependencies. If a target just runs a command, it belongs in whatever script runner your ecosystem already has. Mixing them is fine, but knowing which is which makes the Makefile easier to maintain.