Make: The Task Runner That Refuses to Die

5 min read
Development Tooling Build Systems

Make was created in 1976 to compile C programs. Almost fifty years later, it is still installed on nearly every Unix machine and still one of the most useful task runners ever written. Even in modern JavaScript, Python, and Go projects, a small Makefile is often the cleanest way to wrap commands that everyone on the team needs to run.

Learn more: GNU Make manual

What Make Actually Is

Make is a tool that runs commands based on rules in a file called Makefile. Each rule has a target, optional dependencies, and a recipe — a list of shell commands to run. When you type make build, it finds the build target and executes its commands.

The original purpose was incremental compilation. Make checks file timestamps and only rebuilds what has changed. That feature is still useful for compiled languages, but most modern projects use Make simply as a memorable list of project commands.

A minimal Makefile looks like this:

install:
	npm install

dev:
	npm run dev

test:
	npm test

build:
	npm run build

Now anyone who clones the repo can type make install, make dev, or make test without remembering project-specific scripts. The commands are documented by their existence.

Why Use It in 2026

Modern projects already have task runners. npm run, pnpm, just, task, and language tooling all run scripts. So why bother with Make?

The first reason is uniformity across languages. A real codebase rarely lives in one ecosystem. You have a frontend in Node, a backend in Go, infra scripts in Python, Docker for everything. Each has its own task runner. A single Makefile at the root unifies them. make deploy runs the same way whether it shells out to npm, go, or terraform.

The second reason is availability. Make is on every Linux distribution, every macOS install, every CI runner. There is nothing to install, no version manager, no lock file. New tools come and go — Make stays.

The third reason is discoverability. A Makefile is a single file at the root of the repo that lists everything you can do with the project. New contributors open it and immediately see install, test, lint, deploy. No README scavenger hunt.

Make vs Other Task Runners

Make npm scripts / just / task
Install Pre-installed everywhere Requires installation
Language scope Any language, any tool Often tied to one ecosystem
Dependency tracking Built-in (file timestamps) Usually missing
Syntax Quirky (tabs, escaping) Cleaner, more modern
Cross-platform Painful on Windows Generally better
Stability Unchanged for decades Tools come and go

The Quirks You Need to Know

Make has rough edges. The biggest one: recipes must be indented with tabs, not spaces. Not "either tabs or spaces" — tabs. If your editor inserts spaces, Make fails with a confusing "missing separator" error. Most editors handle this if you tell them the file is a Makefile.

Each line in a recipe runs in its own shell. So this does not work the way you expect:

broken:
	cd frontend
	npm run build

The cd happens, then the shell exits, then npm run build runs in the original directory. To run multiple commands in the same shell, chain them with &&:

build:
	cd frontend && npm run build

Variables use $(VAR), not ${VAR}, and shell variables in recipes need a doubled dollar sign: $$VAR. These are small things, but they trip up everyone the first time.

Phony Targets and Self-Documenting Help

Make assumes a target name is a file. If you have a target called test and a directory called test/, Make thinks the target is already up to date and does nothing. The fix is to declare targets as phony:

.PHONY: install dev test build deploy

install:
	npm install

test:
	npm test

A common pattern is a self-documenting help target that lists the available commands by parsing the Makefile itself:

.PHONY: help
help: ## Show this help message
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

install: ## Install dependencies
	npm install

test: ## Run the test suite
	npm test

Now make help prints every target with its description. New contributors get a self-documenting project entry point for free.

When Not to Use Make

Make is not always the right answer. If your project lives entirely in one ecosystem and the native task runner does the job, adding Make is just another layer. npm run is fine if all you have is npm scripts.

Make also struggles on Windows. The syntax assumes a Unix shell. If your team runs Windows without WSL, expect friction.

For complex builds with conditional logic, dynamic targets, or large dependency graphs, Make's syntax becomes hard to read. At that point a real build system (Bazel, Buck, Nx) or a scripted runner is a better fit.

Bottom Line

Reach for Make when: You want a single, memorable entry point for project commands across multiple languages and tools, and you want it to work everywhere with zero install.

Skip Make when: Your project is single-language, the native task runner already does the job, or your team is primarily on Windows without WSL.

Make is not glamorous. The syntax is from the seventies. But it has outlasted dozens of replacements because it solves a real problem with almost no setup. A twenty-line Makefile at the root of a repo is still one of the cheapest, most durable improvements you can make to a project.