Skip to main content
Make

Makefiles for developers: a command interface, not a second language

The tab error, why cd doesn't persist between recipe lines, and how Make compares to just and npm scripts as a project task runner.

Thien Nguyen
By Thien Nguyen
Updated July 21, 2026 · 3 min read
Makefile:4: *** missing separator.  Stop.

Nearly everyone's first Makefile dies here. The cause is almost never line 4's logic — it's that your editor inserted spaces where Make demands a literal tab. Recipe lines must begin with a tab character, full stop. Configure your editor to keep tabs in Makefile, or you'll fight this on every new file.

Here's a Makefile that avoids the common traps:

.PHONY: test lint deploy

test:
	pnpm test

lint:
	pnpm lint

deploy:
	cd infra && terraform apply -auto-approve

.PHONY matters more than it looks. Without it, make test checks for a file named test; if one ever exists (a test/ directory counts on some setups), Make decides the target is "up to date" and runs nothing. Declaring the target phony says "this is a command, not a file."

The gotcha that wastes an afternoon

Each recipe line runs in its own shell. This does not work:

release:
	cd dist
	./upload.sh    # runs in the ORIGINAL directory, not dist/

The cd applies to a subshell that exits at the end of the line. The next line starts back where you began. Chain them, or set .ONESHELL::

release:
	cd dist && ./upload.sh

The same rule bites with shell variables. $HOME in a recipe is read by Make as a variable and expands to empty; you need $$HOME to pass a literal $ through to the shell.

Make vs just vs npm scripts

Makejustnpm scripts
PreinstalledYes, on macOS/LinuxNoWith Node
Tab-sensitive syntaxYesNoNo (JSON)
Passing argumentsAwkward (make run ARGS=…)First-class (just run foo)Awkward
Tracks file dependenciesYes (its original job)NoNo
Cross-platformWindows needs extra setupYesYes

If you never use Make's file-timestamp dependency tracking — and most task-runner Makefiles don't — just gives you the same discoverable just <task> interface without the tab landmine or the subshell surprise. Make wins when you genuinely want "rebuild output.css only if input.scss changed," which is what it was built for.

FAQ

Why does cd in one recipe line not affect the next?

Because Make runs each line in a separate shell process by default. State — working directory, exported variables — dies with that process. Use && to keep commands in one shell, or put .ONESHELL: at the top to run the whole recipe in a single shell.

How do I pass an argument to a target?

Make has no clean syntax for it. The usual workaround is a variable: make deploy ENV=prod, read inside the recipe as $(ENV). If you pass arguments constantly, that friction is a real reason to look at just.

Do I still need .PHONY if no matching file exists today?

Yes. It documents intent and protects you from the day someone adds a build/ directory or a test file. It also skips an unnecessary stat call. Declaring it costs one line.

A good Makefile stays a thin index of commands people can discover by opening the file. Once a recipe sprouts loops and conditionals, move that logic into a real script and have the recipe call it. The Makefile's job is to make the commands findable, and it does that best when it stays boring.

Cover photo by Stanislav Kondratiev on Pexels.

References

Primary documentation and specifications checked when this article was last updated.

MakeWorkflowCLI

Related articles

All articles