What is a Makefile? A Practical Make Tutorial with Examples
Introduction
Every project has a handful of commands you run over and over: build it, run the tests, run the linter, clean up.
Each project spells them a little differently, with its own flags and its own order, and remembering them is a chore for you and for everyone who joins the project after you.
A Makefile writes them down once, next to the code, and gives each one a short name: make build, make test, make lint.
Make is one of the oldest build tools in a developer's toolbox, and it's still everywhere. You'll find Makefiles in C projects, but also in Go, Kubernetes, Java, and documentation projects.
In this Makefile tutorial, I'll explain what a Makefile is and how to write one, from your first rule to a self-documenting task runner for a Go project. Every example comes with the output it really prints on GNU Make 4.4.1, along with the differences on the GNU Make 3.81 that macOS still ships, including the error messages you're most likely to search for.
What is a Makefile?
A Makefile is a file of rules that the make program reads to build files and run tasks.
Each rule names a target, its prerequisites, and a recipe: the shell commands that create or update the target.
Make runs a recipe only when its target is out of date.
Nothing about it is specific to C.
A target can be a compiled binary, a generated document, or just the name of a task such as test, and a recipe can run any command your shell can.
To use a Makefile, run make in the directory that contains it.
Without arguments, Make builds the default goal (the default target): the first target in the file, not counting special targets that start with a dot, such as .PHONY.
make <target> builds the one you name, and you can name several at once: make clean build.
Make looks for the file under three names, in this order: GNUmakefile, makefile, and Makefile.
The GNU Make manual recommends Makefile, which shows up near other important files such as README in a directory listing.
To read a file with any other name, pass it with -f, as in make -f build.mk.
Stuart Feldman wrote make at Bell Labs in the mid-1970s.
His 1979 paper, Make: a program for maintaining computer programs, says that "Make has been in use on UNIX systems since 1975", while the revision log in the Seventh Edition Unix source starts at "2.1 4/24/76 Base version".
In 2003, Feldman received the ACM Software System Award for it, with a citation that still holds: "there is probably no large software system in the world today that has not been processed by a version or offspring of MAKE."
The make on Linux and macOS is GNU Make, and as of October 2026 its latest release is still 4.4.1, from February 2023.
How to write your first Makefile
Create a file named Makefile with a single rule:
hello:
echo "Hello, Make"The second line starts with a tab character, not with spaces (more on that in the next section).
Now run make in the same directory:
$ make
echo "Hello, Make"
Hello, MakeMake prints each line of the recipe before it runs it, so you see the echo command first, and then its output.
Every rule follows the same structure:
target: prerequisites
recipe- The target is what the rule produces, usually a file name or the name of a task, followed by a colon.
- The prerequisites are the files or other targets that must exist, and be up to date, before the recipe runs. They're optional:
hellohas none. - The recipe is one or more shell commands, each on its own line that starts with a tab.
The GNU Make manual's introduction to rules describes the same three parts, and it warns about that tab: "This is an obscurity that catches the unwary."
Tabs and the missing separator error
Sooner or later, an editor will turn the tab of a recipe into spaces, and Make will refuse to read the file. With eight or more spaces instead of a tab, GNU Make even guesses what happened, a hint it has given since version 3.78, from 1999:
Makefile:2: *** missing separator (did you mean TAB instead of 8 spaces?). Stop.With fewer spaces, you get the bare message:
Makefile:2: *** missing separator. Stop.Both messages are the same on GNU Make 4.4.1 and on macOS's make 3.81. The number after the file name is the line to fix: replace its leading spaces with a single tab.
There's one sneakier case.
If the space-indented line contains a colon, such as @echo "Usage: make hello", Make reads it as a new rule instead, raises no error, and prints make: Nothing to be done for 'hello'.
Tip
To see which lines start with a tab, run cat -et Makefile: it shows each tab as ^I and the end of each line as $, on both macOS and Linux.
GNU cat -A, on Linux, does the same, but macOS's cat answers cat: illegal option -- A.
For the first Makefile, the recipe line starts with ^I, the tab:
$ cat -et Makefile
hello:$
^Iecho "Hello, Make"$To keep it from happening again, tell your editor that Makefiles use tabs. Most editors and IDEs read an EditorConfig file, either natively or through a plugin:
[{Makefile,*.mk}]
indent_style = tabIf you'd rather not use tabs at all, GNU Make 3.82 and later let you choose another recipe prefix with .RECIPEPREFIX:
.RECIPEPREFIX = >
hello:
> echo "Hello, Make"This works on GNU Make 4.4.1, but macOS's make 3.81 predates it and stops with Makefile:3: *** missing separator. Stop.
I'd keep the tab in any Makefile that someone might run on a Mac.
Why a tab, of all characters? Feldman explained it years later, in a quote that Eric S. Raymond includes in The Art of Unix Programming:
"Why the tab in column 1? Yacc was new, Lex was brand new. I hadn't tried either, so I figured this would be a good excuse to learn. After getting myself snarled up with my first stab at Lex, I just did something simple with the pattern newline-tab. It worked, it stayed. And then a few weeks later I had a user population of about a dozen, most of them friends, and I didn't want to screw up my embedded base. The rest, sadly, is history."
Prerequisites and incremental builds
Make's real job is to rebuild only what changed.
This rule creates greeting.txt from name.txt:
greeting.txt: name.txt
sed 's/^/Hello, /' name.txt > greeting.txtCreate name.txt with echo Marc > name.txt, then run make.
greeting.txt doesn't exist yet, so Make runs the recipe:
$ make
sed 's/^/Hello, /' name.txt > greeting.txt
$ cat greeting.txt
Hello, MarcRun make again, and greeting.txt is now newer than name.txt, so there's nothing to do:
$ make
make: 'greeting.txt' is up to date.On macOS's make 3.81, the message quotes the name differently: make: `greeting.txt' is up to date.
Update the prerequisite, and the target is out of date again:
$ touch name.txt
$ make
sed 's/^/Hello, /' name.txt > greeting.txtMake decides by comparing modification times: a target is out of date when it doesn't exist, or when any of its prerequisites is newer than it.
If you script these steps, wait a second before the touch: Make only sees a change when the prerequisite's timestamp is later than the target's, and macOS's make compares them in whole seconds.
The prerequisites can be other targets, and that's where Make shines.
Add a rule that builds shout.txt from greeting.txt, above the first one:
shout.txt: greeting.txt
tr a-z A-Z < greeting.txt > shout.txt
greeting.txt: name.txt
sed 's/^/Hello, /' name.txt > greeting.txtNow make builds shout.txt, the new first target, and a change to name.txt rebuilds both files, in order:
$ touch name.txt
$ make
sed 's/^/Hello, /' name.txt > greeting.txt
tr a-z A-Z < greeting.txt > shout.txt
$ cat shout.txt
HELLO, MARCA change to greeting.txt alone only rebuilds shout.txt.
In a real project, the targets are object files, binaries, or generated code, and Make walks the same chain of prerequisites to rebuild only what a change affects.
You can choose a different default goal with .DEFAULT_GOAL, as the task runner at the end of this post does.
Phony targets
Make assumes that every target is a file. That's a problem for targets that name a task instead:
docs:
@echo "Generating the docs"The @ at the start of the recipe line hides the command, so only its output shows.
If the project also has a docs directory, Make treats it as the target (to Make, a directory is a file too).
The rule has no prerequisites that could make it out of date, so Make runs nothing:
$ mkdir docs
$ make docs
make: 'docs' is up to date.Declare the target as phony, and Make runs its recipe no matter which files exist:
.PHONY: docs
docs:
@echo "Generating the docs"$ make docs
Generating the docsThe same trap catches a test target as soon as someone creates a file named test, or a build target in a project with a build directory.
The GNU Make manual's section on phony targets adds a second reason to use them: Make doesn't look for a built-in (implicit) rule to build a phony target, which saves it some work.
Every target that names a task rather than a file belongs in .PHONY, which is why a task-runner Makefile declares all of its targets phony.
Variables
Variables keep values in one place, and GNU Make has two main flavors of them. The difference is when the value is expanded:
LAZY = Hello, $(NAME)
EAGER := Hello, $(NAME)
NAME = Make
show:
@echo "lazy=[$(LAZY)] eager=[$(EAGER)]"$ make
lazy=[Hello, Make] eager=[Hello, ]=defines a recursively expanded variable. Make storesHello, $(NAME)as it is and expands it every time the variable is used, and by thenNAMEis defined.:=defines a simply expanded variable. Make expands the value once, where it's defined, andNAMEwas still empty at that point.
Prefer := for a value computed with $(shell ...), the function that runs a command and returns its output, so the command runs once instead of every time the variable is used.
The GNU Make manual's page on the flavors of variables covers the less common ones.
Two more operators cover most of what's left.
?= sets a default: it assigns the variable only if it isn't defined yet.
NAME ?= World
hello:
@echo "Hello, $(NAME)"$ make
Hello, World
$ make NAME=Marc
Hello, Marc
$ NAME=Env make
Hello, EnvA value on the command line, as in make NAME=Marc, always overrides the variable.
An environment variable overrides it too, but only because ?= sets a default.
Change the first line to a plain assignment, and the Makefile wins over the environment:
NAME = World
hello:
@echo "Hello, $(NAME)"$ NAME=Env make
Hello, World
$ NAME=Env make -e
Hello, Env
$ make NAME=Marc
Hello, MarcThe -e option gives the environment the upper hand, and the command line still wins over both.
+= appends a value to a variable, separated by a space:
TEST_FLAGS = -v
TEST_FLAGS += -race
test:
@echo "go test $(TEST_FLAGS) ./..."$ make
go test -v -race ./...Conditionals
Make also has conditionals, which it evaluates while it reads the Makefile, before any recipe runs.
ifeq compares two values, here the output of uname -s, and else covers every other case:
ifeq ($(shell uname -s),Darwin)
OPEN := open
else
OPEN := xdg-open
endifOn macOS, $(OPEN) is open, and on Linux it's xdg-open, so a recipe can open a generated report with $(OPEN) coverage.html on both.
ifdef only checks whether a variable has a value.
A common use is a quiet build: set Q := @ unless VERBOSE is defined, start each recipe line with $(Q), and make VERBOSE=1 shows the commands.
Any value counts, so make VERBOSE=0 shows them too.
Automatic variables and pattern rules
While it runs a recipe, Make sets a few automatic variables that describe the rule, so you don't have to repeat its file names:
build/page.txt: header.txt body.txt
mkdir -p $(@D)
cat $^ > $@
@echo "target=$@ first=$< all=$^ newer=$?"With a header.txt and a body.txt in place, the first run prints:
$ make
mkdir -p build
cat header.txt body.txt > build/page.txt
target=build/page.txt first=header.txt all=header.txt body.txt newer=header.txt body.txtThe target didn't exist, so both prerequisites count as newer.
After touch body.txt, only the changed one does:
$ touch body.txt
$ make
mkdir -p build
cat header.txt body.txt > build/page.txt
target=build/page.txt first=header.txt all=header.txt body.txt newer=body.txt| Variable | Contains | In the example |
|---|---|---|
$@ | The target | build/page.txt |
$< | The first prerequisite | header.txt |
$^ | All the prerequisites, without duplicates | header.txt body.txt |
$? | The prerequisites newer than the target | Both on the first run, body.txt after touch body.txt |
$(@D) | The directory part of the target | build |
The GNU Make manual lists every automatic variable, but these five cover most Makefiles.
Pattern rules
A pattern rule uses % to match any part of a file name, so a single rule covers every file of a kind:
%.upper: %.txt
tr a-z A-Z < $< > $@$ echo hello > a.txt
$ make a.upper
tr a-z A-Z < a.txt > a.upper
$ cat a.upper
HELLO
$ make b.upper
make: *** No rule to make target 'b.upper'. Stop.For make a.upper, the % matches a, so the prerequisite is a.txt, $< is a.txt, and $@ is a.upper.
There's no b.txt, so no rule can make b.upper, and Make stops with make: *** No rule to make target 'b.upper'. Stop.
This is how a C Makefile compiles every .c file into a .o file with a single %.o: %.c rule.
How recipes run
Make runs each line of a recipe in its own shell, so a cd on one line is gone by the next:
where:
cd /
pwd
cd / && pwd$ make
cd /
pwd
/home/marc/project
cd / && pwd
/The first pwd prints the directory you ran make in, not /.
To run several commands in the same shell, join them on one line with &&, which also stops at the first command that fails.
When that line gets long, end each part with a \, and Make passes the joined lines to a single shell:
where:
cd / && \
pwdThis pwd prints /.
The GNU Make manual's page on recipe execution explains the details.
GNU Make 3.82 and later can also run the whole recipe in a single shell, with the .ONESHELL special target:
.ONESHELL:
where:
cd /
pwdOn GNU Make 4.4.1, this pwd prints /, while macOS's make 3.81 silently ignores .ONESHELL and prints the directory you ran make in again.
There's a catch: with .ONESHELL, Make only notices a failure on the last line of the recipe, unless you also add .SHELLFLAGS += -e.
Before passing a recipe line to the shell, Make expands its variables.
So a $ meant for the shell must be written as $$:
home:
@echo "Make variable: $(HOME)"
@echo "Shell variable: $$HOME"
@for f in a b; do echo "Item $$f"; done$ make
Make variable: /home/marc
Shell variable: /home/marc
Item a
Item b$(HOME) works because Make turns every environment variable into a Make variable, and $$HOME reaches the shell as $HOME.
A loop variable only exists in the shell, so it always needs the double dollar: with a single $, Make would expand $f, a Make variable named f, to nothing.
Two prefixes change how Make treats a recipe line.
@ hides the command, as in the docs target, and - ignores its failure:
check:
-false
@echo "Still running"
false
@echo "Never printed"$ make
false
make: [Makefile:2: check] Error 1 (ignored)
Still running
false
make: *** [Makefile:4: check] Error 1Make reports the failure on line 2 and carries on.
The failure on line 4 stops it, so the last line never runs, and make exits with status 2.
macOS's make 3.81 prints the same messages without the file name and line number, make: [check] Error 1 (ignored) and make: *** [check] Error 1: GNU Make 4.0 added them.
Command-line options
These are the command-line options I use the most:
| Option | What it does |
|---|---|
-n | Prints the commands it would run, without running them (a dry run) |
-j N | Runs up to N recipes at the same time; -j alone sets no limit |
-C dir | Changes to dir before reading the Makefile |
-f file | Reads file instead of GNUmakefile, makefile, or Makefile |
-B | Rebuilds every target, even the ones that are up to date |
VAR=value | Sets a variable, overriding the Makefile and the environment |
-e | Lets environment variables override the Makefile's own values |
A dry run is a quick way to see what a target does before you run it, and it even prints the commands that @ hides:
build:
@echo "Building"
go build -o hello .$ make -n build
echo "Building"
go build -o hello .It isn't fully side-effect free, though: Make still runs every $(shell ...) in the Makefile while it reads it.
-j runs independent targets in parallel, so their output can arrive in a different order.
With a slow target that sleeps for a second and a fast one:
all: slow fast
slow:
@sleep 1; echo "slow done"
fast:
@echo "fast done"$ make
slow done
fast done
$ make -j2
fast done
slow done-C runs another directory's Makefile without leaving the current one.
With the first Makefile in a hello directory, GNU Make 4.4.1 prints the directory it enters and leaves:
$ make -C hello
make: Entering directory '/home/marc/hello'
echo "Hello, Make"
Hello, Make
make: Leaving directory '/home/marc/hello'macOS's make prints neither line unless you add -w.
Make as a task runner
In many projects, Make builds nothing by itself.
It gives the project's commands short names, so everyone, and every CI pipeline, runs them the same way.
Here is a Makefile for a Go project, with a self-documenting help target:
.DEFAULT_GOAL := help
BINARY ?= hello
.PHONY: help build test clean
help: ## Show this help
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9\/\.-]+:.*?##/ { printf " \033[36m%-21s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
build: ## Build the binary
go build -o $(BINARY) .
test: ## Run the tests
go test ./...
clean: ## Remove the binary
rm -f $(BINARY)I ran it in a Go module named marcnuri.com/hello, with a main.go and a single test.
If you're starting from scratch, see how to initialize a Go project with modules.
A plain make prints the help, in color in a terminal (shown here without the color codes):
$ make
Usage:
make <target>
help Show this help
build Build the binary
test Run the tests
clean Remove the binaryThe other targets run the Go commands:
$ make build
go build -o hello .
$ ./hello
Hello, Make$ make test
go test ./...
ok marcnuri.com/hello 0.001s$ make BINARY=greeter build
go build -o greeter .
$ make clean
rm -f helloEach part of the Makefile uses something from the previous sections:
.DEFAULT_GOAL := helpmakes a plainmakeprint the help instead of building the first target. It's available since GNU Make 3.81, so it works on macOS too.BINARY ?= hellois a default that the command line overrides, asmake BINARY=greeter buildshows..PHONYlists every target, because none of them produces a file with its own name.- The
## ...comment after each target is its description. Thehelprecipe runsawkover$(MAKEFILE_LIST), the list of every Makefile that Make has read, and prints eachtarget: ## descriptionline it finds. A line that starts with##@, such as##@ Build, prints a heading above the targets that follow it, to group the targets of a larger Makefile.
The awk program is Chaos Mesh's (Apache-2.0), an adaptation of the grouped ##@ help target in Suvash Thapaliya's Well documented Makefiles (2017) that also accepts / and . in target names.
You'll also find the pattern in its most copied form, marmelab's Self-Documented Makefile (François Zaninotto, 2016), which pipes grep into awk.
It works for a single file, but I prefer the awk-only version above for two reasons I could reproduce on both GNU Make 4.4.1 and macOS's 3.81.
Once a Makefile uses include, grep prefixes each match with its file name, and marmelab's help lists Makefile and tools.mk where the target names should be.
Its pattern also skips targets with a / or a digit in their name, such as test/cover or k8s-up.
The awk program above lists every target that has a ## description, including the ones in included files and the ones with a / or a digit in their name.
For a real-world example, check the Makefile of a kubectl plugin written in Go. For the next step, see a time-saving Makefile for Go projects by Alex Edwards, which adds targets for auditing, formatting, and live reloading.
GNU Make on macOS and Windows
Warning
As of October 2026, macOS ships GNU Make 3.81, released in 2006.
Features added since then, such as !=, .ONESHELL, and .RECIPEPREFIX, either fail silently or stop with missing separator, so a Makefile that works on Linux can behave differently on a Mac.
Check which version you have with make --version.
On macOS 26.7.1, with the Command Line Tools 27.0 installed, its first line is:
$ make --version | head -n 1
GNU Make 3.81The rest of the output says the binary was "built for i386-apple-darwin11.3.0". GNU Make 3.81 is the last release under the GPLv2 license; 3.82, from July 2010, was the first under GPLv3, which is the likely reason Apple never upgraded, though Apple has never said so. Its latest source release of the tool, gnumake-136 (May 2024), still declares version 3.81.
The silent failures are the dangerous ones.
In GNU Make 4.0 and later, != assigns the output of a shell command to a variable:
VERSION != echo 1.2.3
SHELL_VERSION := $(shell echo 1.2.3)
show:
@echo "!= gives '$(VERSION)', shell gives '$(SHELL_VERSION)'"$ make
!= gives '1.2.3', shell gives '1.2.3'macOS's make 3.81 reads the first line as a plain = assignment to a variable named VERSION !, raises no error, and prints != gives '', shell gives '1.2.3'.
$(shell ...) works on both versions, so it's the portable choice.
| Feature | GNU Make 4.4.1 | macOS make 3.81 |
|---|---|---|
VAR != command (GNU Make 4.0) | Assigns the command's output | Defines a variable named VAR !, no error |
.ONESHELL: (3.82) | Runs each recipe in one shell | Silently ignored |
.RECIPEPREFIX (3.82) | Replaces the tab | missing separator |
| A failing recipe line | make: *** [Makefile:4: check] Error 1 | make: *** [check] Error 1 |
make -C dir | Prints Entering directory and Leaving directory | Prints neither without -w (a change in Apple's build) |
| Timestamps | Finer than a second, where the file system allows | Whole seconds |
| Names in messages | 'greeting.txt' | `greeting.txt' |
Apart from these differences, every example in this post, the help target included, behaves the same on both versions.
Installing a newer GNU Make on macOS
As of October 2026, Homebrew's make formula installs GNU Make 4.4.1 as gmake, so it doesn't replace the system's make:
brew install make
gmake --versionTo call it make, put its gnubin directory first in your PATH, as the formula's caveat suggests, for example in your ~/.zshrc:
export PATH="$(brew --prefix)/opt/make/libexec/gnubin:$PATH"If other people will run your Makefile on a stock Mac, don't rely on that: stick to the features that 3.81 supports.
Make on Windows
Windows doesn't include Make, but you have several options:
- WSL runs a Linux distribution, and you install Make with its package manager, in whatever version that distribution ships.
- MSYS2 installs GNU Make 4.4.1 (as of October 2026) with
pacman -S make. - Chocolatey installs GNU Make 4.4.1 (as of October 2026) with
choco install make. It's a native Windows build, so its recipes run incmd.exeunless it finds ash.exe, such as Git Bash's, on yourPATH.
Avoid GnuWin32's Make, which you'll still find in old answers: it's GNU Make 3.81, last updated in 2006.
When to use Make, and when not
Make's strengths are easy to list.
It's a package away on every Linux distribution and part of the Command Line Tools on macOS, so most developers already have it.
It knows which files are out of date, which a plain command runner doesn't.
And one Makefile at the root of a repository is a convention everyone recognizes.
Its weaknesses are just as real: the tab rule, the differences between versions, a syntax that gets cryptic past the simple cases, and a recipe language that is really shell, which makes it awkward on Windows.
Portability is thinner than it looks, too.
BSD systems ship their own make, which shares the rule syntax but uses different directives (.if, .include), and the POSIX standard only added .PHONY, ?=, +=, and -include to its make in POSIX.1-2024.
If those get in your way, these are the alternatives I'd look at, as of October 2026:
- just is "a command runner, not a build system", with a Make-like syntax and no need for
.PHONY. - Task is a task runner configured in a YAML file,
Taskfile.yml. - npm scripts live in
package.json, a natural fit for JavaScript projects. - A shell script is enough for two or three commands that never need to skip work.
- Maven and Gradle already define the build, test, and package steps of a Java project.
- CMake describes C and C++ builds and generates Makefiles (or Ninja files) from them.
- Ninja is "a small build system with a focus on speed", meant to be generated by a tool such as CMake rather than written by hand.
- Bazel is a build and test tool for large, multi-language codebases, "similar to Make, Maven, and Gradle".
Frequently Asked Questions
Is Make only for C and C++?
No. Make doesn't know which language it builds: it compares file times and runs shell commands. This post's examples generate text files and drive a Go project, and Java, Python, documentation, and infrastructure projects use it the same way.
What does "No rule to make target" mean?
make: *** No rule to make target 'nope'. Stop. means that you asked for a target that isn't a file and that no rule in the Makefile can build: check its spelling.
When the missing file is a prerequisite, the message names the rule that needs it, as in make: *** No rule to make target 'missing.txt', needed by 'all'. Stop., so check that file's path.
What does "Nothing to be done for" mean?
make: Nothing to be done for 'all'. means that the target has no recipe of its own and nothing it depends on needed rebuilding.
It isn't an error: Make checked everything and found no work to do.
Makefile, makefile or GNUmakefile?
Use Makefile.
Keep GNUmakefile for a file that relies on GNU-only features, since other implementations of make don't look for it.
Don't keep both: Make reads GNUmakefile and ignores Makefile.
Is Make still relevant?
Yes.
About 50 years after Feldman wrote it, Make is still the entry point of countless projects, and current frameworks still build on it: the Kubebuilder quick start, for example, drives a new Kubernetes operator with make install, make run, and make deploy.
Newer task runners are pleasant to use, but a Makefile asks contributors to install nothing new.
Conclusion
A Makefile is a list of rules: a target, its prerequisites, and a recipe that starts with a tab.
With those three parts, Make rebuilds only what changed, and with .PHONY, a few variables, and a help target, it becomes the task runner of a project.
Indent recipes with real tabs, and you'll rarely see missing separator again.
Keep the 3.81 that macOS ships in mind, and your Makefile will run the same on a Mac.
When you need more, the GNU Make manual and makefiletutorial.com cover what this post leaves out.
A Makefile gives every contributor, human or agent, one stable command to build, test, and lint a project.
