thisago's blog


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:

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:

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.json was all called in parallel.
  • The deps target (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.json and node_modules/.dirstamp, the dist was 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
  • foreach and call command
  • .dirstamp trick to track generation of directories
  • Patterns
  • PHONY

Now it's your time:

make the_future

I'm certain that the_future is PHONY target, but I hope it's not a canned recipe!