A logo showing the text blog.marcnuri.com
Español
Home»Tools»What is a Makefile? A Practical Make Tutorial with Examples

Recent Posts

  • Fabric8 Kubernetes Client 8.0 is now available!
  • Fabric8 Kubernetes Client 7.9 is now available!
  • Eclipse JKube 1.20 is now available!
  • Project, Agent, Person: The Missing Axis of AI Coding Tools
  • Fabric8 Kubernetes Client 7.8 is now available!

Categories

  • Artificial Intelligence
  • Backend Development
  • Cloud Native
  • Engineering Insights
  • Frontend Development
  • JavaScript
  • Legacy
  • Operations
  • Personal
  • Pet projects
  • Quality Engineering
  • Tools

Archives

  • September 2026
  • July 2026
  • June 2026
  • May 2026
  • April 2026
  • March 2026
  • February 2026
  • January 2026
  • December 2025
  • October 2025
  • September 2025
  • July 2025
  • May 2025
  • April 2025
  • March 2025
  • February 2025
  • January 2025
  • December 2024
  • November 2024
  • August 2024
  • June 2024
  • May 2024
  • April 2024
  • March 2024
  • February 2024
  • January 2024
  • December 2023
  • November 2023
  • October 2023
  • September 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • April 2023
  • March 2023
  • February 2023
  • January 2023
  • December 2022
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • July 2022
  • June 2022
  • May 2022
  • March 2022
  • February 2022
  • January 2022
  • December 2021
  • November 2021
  • October 2021
  • September 2021
  • August 2021
  • July 2021
  • January 2021
  • December 2020
  • November 2020
  • October 2020
  • September 2020
  • August 2020
  • July 2020
  • June 2020
  • May 2020
  • March 2020
  • February 2020
  • January 2020
  • December 2019
  • November 2019
  • October 2019
  • September 2019
  • July 2019
  • March 2019
  • November 2018
  • July 2018
  • June 2018
  • May 2018
  • April 2018
  • March 2018
  • February 2018
  • December 2017
  • October 2017
  • August 2017
  • July 2017
  • January 2017
  • May 2016
  • December 2015
  • November 2015
  • December 2014
  • November 2014
  • October 2014
  • March 2014
  • August 2012
  • February 2011
  • January 2010
  • November 2008
  • June 2008
  • May 2008
  • April 2008
  • January 2008
  • November 2007
  • September 2007
  • August 2007
  • July 2007
  • June 2007
  • May 2007
  • April 2007
  • March 2007

What is a Makefile? A Practical Make Tutorial with Examples

2010-01-02 in Tools tagged Build / Automation / Shell / Linux by Marc Nuri | Last updated: 2026-11-01
Versión en Español

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:

Makefile
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:

bash
$ make
echo "Hello, Make"
Hello, Make

Make 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:

Makefile
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: hello has 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:

Output
Makefile:2: *** missing separator (did you mean TAB instead of 8 spaces?).  Stop.

With fewer spaces, you get the bare message:

Output
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:

bash
$ 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:

.editorconfig
[{Makefile,*.mk}]
indent_style = tab

If you'd rather not use tabs at all, GNU Make 3.82 and later let you choose another recipe prefix with .RECIPEPREFIX:

Makefile
.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:

Makefile
greeting.txt: name.txt
	sed 's/^/Hello, /' name.txt > greeting.txt

Create name.txt with echo Marc > name.txt, then run make. greeting.txt doesn't exist yet, so Make runs the recipe:

bash
$ make
sed 's/^/Hello, /' name.txt > greeting.txt
$ cat greeting.txt
Hello, Marc

Run make again, and greeting.txt is now newer than name.txt, so there's nothing to do:

bash
$ 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:

bash
$ touch name.txt
$ make
sed 's/^/Hello, /' name.txt > greeting.txt

Make 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:

Makefile
shout.txt: greeting.txt
	tr a-z A-Z < greeting.txt > shout.txt
greeting.txt: name.txt
	sed 's/^/Hello, /' name.txt > greeting.txt

Now make builds shout.txt, the new first target, and a change to name.txt rebuilds both files, in order:

bash
$ touch name.txt
$ make
sed 's/^/Hello, /' name.txt > greeting.txt
tr a-z A-Z < greeting.txt > shout.txt
$ cat shout.txt
HELLO, MARC

A 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:

Makefile
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:

bash
$ 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:

Makefile
.PHONY: docs
docs:
	@echo "Generating the docs"
bash
$ make docs
Generating the docs

The 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:

Makefile
LAZY = Hello, $(NAME)
EAGER := Hello, $(NAME)
NAME = Make
show:
	@echo "lazy=[$(LAZY)] eager=[$(EAGER)]"
bash
$ make
lazy=[Hello, Make] eager=[Hello, ]
  • = defines a recursively expanded variable. Make stores Hello, $(NAME) as it is and expands it every time the variable is used, and by then NAME is defined.
  • := defines a simply expanded variable. Make expands the value once, where it's defined, and NAME was 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.

Makefile
NAME ?= World
hello:
	@echo "Hello, $(NAME)"
bash
$ make
Hello, World
$ make NAME=Marc
Hello, Marc
$ NAME=Env make
Hello, Env

A 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:

Makefile
NAME = World
hello:
	@echo "Hello, $(NAME)"
bash
$ NAME=Env make
Hello, World
$ NAME=Env make -e
Hello, Env
$ make NAME=Marc
Hello, Marc

The -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:

Makefile
TEST_FLAGS = -v
TEST_FLAGS += -race
test:
	@echo "go test $(TEST_FLAGS) ./..."
bash
$ 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:

Makefile
ifeq ($(shell uname -s),Darwin)
OPEN := open
else
OPEN := xdg-open
endif

On 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:

Makefile
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:

bash
$ 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.txt

The target didn't exist, so both prerequisites count as newer. After touch body.txt, only the changed one does:

bash
$ 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
VariableContainsIn the example
$@The targetbuild/page.txt
$<The first prerequisiteheader.txt
$^All the prerequisites, without duplicatesheader.txt body.txt
$?The prerequisites newer than the targetBoth on the first run, body.txt after touch body.txt
$(@D)The directory part of the targetbuild

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:

Makefile
%.upper: %.txt
	tr a-z A-Z < $< > $@
bash
$ 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:

Makefile
where:
	cd /
	pwd
	cd / && pwd
bash
$ 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:

Makefile
where:
	cd / && \
	pwd

This 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:

Makefile
.ONESHELL:
where:
	cd /
	pwd

On 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 $$:

Makefile
home:
	@echo "Make variable: $(HOME)"
	@echo "Shell variable: $$HOME"
	@for f in a b; do echo "Item $$f"; done
bash
$ 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:

Makefile
check:
	-false
	@echo "Still running"
	false
	@echo "Never printed"
bash
$ make
false
make: [Makefile:2: check] Error 1 (ignored)
Still running
false
make: *** [Makefile:4: check] Error 1

Make 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:

OptionWhat it does
-nPrints the commands it would run, without running them (a dry run)
-j NRuns up to N recipes at the same time; -j alone sets no limit
-C dirChanges to dir before reading the Makefile
-f fileReads file instead of GNUmakefile, makefile, or Makefile
-BRebuilds every target, even the ones that are up to date
VAR=valueSets a variable, overriding the Makefile and the environment
-eLets 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:

Makefile
build:
	@echo "Building"
	go build -o hello .
bash
$ 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:

Makefile
all: slow fast
slow:
	@sleep 1; echo "slow done"
fast:
	@echo "fast done"
bash
$ 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:

bash
$ 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:

Makefile
.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):

bash
$ make

Usage:
  make <target>
  help                   Show this help
  build                  Build the binary
  test                   Run the tests
  clean                  Remove the binary

The other targets run the Go commands:

bash
$ make build
go build -o hello .
$ ./hello
Hello, Make
bash
$ make test
go test ./...
ok  	marcnuri.com/hello	0.001s
bash
$ make BINARY=greeter build
go build -o greeter .
$ make clean
rm -f hello

Each part of the Makefile uses something from the previous sections:

  • .DEFAULT_GOAL := help makes a plain make print the help instead of building the first target. It's available since GNU Make 3.81, so it works on macOS too.
  • BINARY ?= hello is a default that the command line overrides, as make BINARY=greeter build shows.
  • .PHONY lists every target, because none of them produces a file with its own name.
  • The ## ... comment after each target is its description. The help recipe runs awk over $(MAKEFILE_LIST), the list of every Makefile that Make has read, and prints each target: ## description line 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:

bash
$ make --version | head -n 1
GNU Make 3.81

The 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:

Makefile
VERSION != echo 1.2.3
SHELL_VERSION := $(shell echo 1.2.3)
show:
	@echo "!= gives '$(VERSION)', shell gives '$(SHELL_VERSION)'"
bash
$ 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.

FeatureGNU Make 4.4.1macOS make 3.81
VAR != command (GNU Make 4.0)Assigns the command's outputDefines a variable named VAR !, no error
.ONESHELL: (3.82)Runs each recipe in one shellSilently ignored
.RECIPEPREFIX (3.82)Replaces the tabmissing separator
A failing recipe linemake: *** [Makefile:4: check] Error 1make: *** [check] Error 1
make -C dirPrints Entering directory and Leaving directoryPrints neither without -w (a change in Apple's build)
TimestampsFiner than a second, where the file system allowsWhole 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:

bash
brew install make
gmake --version

To call it make, put its gnubin directory first in your PATH, as the formula's caveat suggests, for example in your ~/.zshrc:

bash
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 in cmd.exe unless it finds a sh.exe, such as Git Bash's, on your PATH.

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.

Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Post navigation
BeansBinding Performance (Issue 37)What is an Immediately Invoked Function Expression (IIFE)?
© 2007 - 2026 Marc Nuri