Make: The Task Runner That Refuses to Die
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.
Style guide: A practical Makefile style guide
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.