Makefile is Awesome
Table of Contents
__________________________________________
< $ make help help help please_help; touch >
------------------------------------------
\ . . .
\ . . . ` ,
\ .; . : .' : : : .
\ i..`: i` i.i.,i i .
\ `,--.|i |i|ii|ii|i:
UOOU\.'@@@@@@`.||'
\__/(@@@@@@@@@@)'
(@@@@@@@@)
`YY~~~~YY'
|| ||
Jokes aside, the simplicity of how GNU Make solves complex dependency chains and decides what should be built, is awesome.
Motivated by a work pal in our Go codebase, I started to enjoy with Make:
- Seamless integration with shell.
- Preprocessing for shell scripts.
- Powerful dependency management.
- Tabs instead of spaces… Hum, no, this I dislike.
And after almost a year using it as a ordinary task runner, I started to pay more attention in Makefiles of open source Go projects, and I started to get more interested in it. Then reading more of its Info pages, I got the basics:
- Targets
- Recipes and Canned recipes
- Variables flavors
- Parallelism
- Dependencies and order-only dependencies
All this stuff is pretty much a swissarmy knife for building files.
Walkthrough
We use make even for front-end. Below I documented a example structure for
building a Vite app.
The structure is basically:
[data/{a,b,c}.json] -> [data/%-processed.json]
|
[clean] v
[node_modules/.dirstamp] ------> [dist]
# Adding parallelism by default MAKEFLAGS += -j4 # For tests purposes, appending to this file to log the execution sequence across parallel calls COMMAND_LOG := commands.txt # We can define a canned recipe to add the log to the text file # Important: Ensure to use `=` instead `:=`. The single equal sign makes the # variable expand in the caller, and colon-equal sign expands once at definition, # and at definition the `$1` variable doesn't exists yet (injected by `call` function). # This is called the two variable "flavors". Read more at '(make) Flavors' log-cmd = echo "$$(date -Is): $1" >>$(COMMAND_LOG) # It can be multi-line as well: # define log-cmd # echo "$$(date -Is): $1" >>$(COMMAND_LOG) # endef # When calling `make` with no goal, run `dist`. See '(make) Special Variables' .DEFAULT_GOAL = dist # `&:` means a "grouped target". It means this recipe outputs the all files at once. See Info '(make) Multiple Targets' # It's executed if any of its files needs to be generated, and no matter how much is missing, it runs only once data/a.json data/b.json data/c.json&: # The leading @ suppress the print of called command in stdout @sleep 1 # Adding sleep to test parallelism # The `call` lets you provide parameters for canned recipes. See '(make) Call Function' $(call log-cmd,generate data to fulfill $@) mkdir -p data/ touch data/{a,b,c}.json # Some command that processes data and outputs a intermediary file. Dummy actions for exemplification # The percentage defines a pattern target, we can see it as this regex: "^data/(.+)-processed\.json$", # But it's only available if the dependency "^data/(.+)\.json$" exists. See '(make) Pattern Rules' data/%-processed.json: data/%.json @sleep 1 touch '$@' $(call log-cmd,processed $< data to fulfill $@) # The .dirstamp is a trick to let Make know when it's time to update the dependencies. # It generates a blank file inside the dependencies dir so it can compare the modification time against the lock file. node_modules/.dirstamp: package-lock.json @sleep 2 $(call log-cmd,npm install) mkdir -p node_modules/ # Manually creating because we're not really running `npm install` touch '$@' # Important to satisfy the target # We can even define an alias for deps installation command .PHONY: deps deps: node_modules/.dirstamp # We can easily depend on any target file of its recipe # Directories are not good as targets as they're mostly called all the time, therefore I'm using .PHONY which make it run all the time. # For $(foreach VAR,LIST,TEXT), see '(make) Foreach Function' .PHONY: dist dist: deps $(foreach x,a b c,data/$x-processed.json) @sleep 1 $(call log-cmd,built the app) # The leading dash instructs Make to ignore the error. See '(make) Errors' .PHONY: clean clean: -rm -r node_modules/ data/ dist/ -rm $(COMMAND_LOG)
make clean >/dev/null # Calling clean separately to prevent running it in parallel with the rest \time -f 'not parallel took %E' make -j1 dist 2>&1 >/dev/null make clean >/dev/null \time -f 'parallel took %E' make dist 2>&1 >/dev/null
not parallel took 0:07.08 parallel took 0:03.03
And let's see the call sequence:
2026-05-25T08:57:11-03:00: processed data/a.json data to fulfill data/a-processed.json 2026-05-25T08:57:11-03:00: processed data/c.json data to fulfill data/c-processed.json 2026-05-25T08:57:11-03:00: processed data/b.json data to fulfill data/b-processed.json
Highlights:
- The grouped target for data generation was called only once as expected.
- The target
data/%-processed.jsonwas all called in parallel. - The
depstarget (2s) was called in parallel among the processing (1s), absorbing half of the real time. - In the end, after having
data/{a,b,c}-processed.jsonandnode_modules/.dirstamp, thedistwas called.
Is't that awesome?
What's Next
This condensed example shown:
- Parallelism
- The two flavors of variables
- Canned rules
- Grouped target
- Ignore error in specific command
foreachandcallcommand.dirstamptrick to track generation of directories- Patterns
- PHONY
make the_future
I'm certain that the_future is PHONY target, but I hope it's not a canned recipe!