Makefiles - Still a Useful Tool for the Modern Software Engineer
Makefiles are an old technology, from days of C. They contain recipes for making a file. A recipe describes how to build a target file from its dependencies.
Even in newer technologies we still want to make files from other files—it’s just that the world has moved on a bit. Makefiles remain useful because they solve a concrete problem: they track dependencies between files and only re-run commands when needed.
Three example makefiles illustrate their continued use.
Compiling a Go project
They ain’t just for C anymore.
You’re in a team, and the new person wants to know how to run and build the codebase. Familiar problem? You could maintain documentation every commit and trust the pipeline to get it to production, but how does it run on your computer? The makefile answers two problems here.
First, it does it all. Just run make and you’re done.
Second, as a text file it explains the processes involved.
.PHONY: build test fmt lint clean run
# Default target - runs when you just type 'make'
build:
go build -o bin/myapp .
test:
go test ./...
fmt:
go fmt ./...
lint:
golangci-lint run
clean:
rm -rf bin/
run: build
./bin/myapp
# Special target that runs before other targets
.PHONY: all
all: fmt lint test build
How it works
.PHONYmarks targets that don’t represent actual files (they’re just command labels)- Each target can have dependencies listed after the colon:
run: buildmeans “run depends on build” - When you run
make run, Make first checks ifbuildis up to date, runs it if needed, then runsrun - Indentation must be a real tab character (this trips everyone up)
- Targets with no dependencies always run; targets with file dependencies only run if the dependency is newer
Why it’s useful here
New team members see one command: make all. They don’t need to remember whether to run tests before linting, or where the binary goes. The makefile is also version-controlled, so it stays in sync with the code. When you change the build process, you update one file that everyone uses.
Where it breaks
Make is a local tool. It works great when everyone runs the same process on their machine, but breaks down for CI/CD. What if a developer has go fmt configured differently? What if they have an older version of golangci-lint? Make doesn’t enforce consistency across environments—it just runs recipes.
This is where GitHub Actions (or GitLab CI, CircleCI, etc.) wins. A CI pipeline runs in a controlled environment with pinned tool versions. It catches problems that slip through local makefiles. The pipeline also blocks merges automatically, whereas Make just politely fails. For any project with multiple developers and a production deployment, you want both: Make for local development ergonomics, and a pipeline for correctness guarantees.
Markdown, Mermaid, and Structurizr to a document
Not a programmer anymore? Make still answers a lot of the problems with using other text documents. We can combine CLI tools to generate diagrams and convert output into PDF or HTML files as your preference goes.
.PHONY: clean build
# Output directory
OUT = build
# Source files
DIAGRAMS = docs/architecture.mmd docs/flow.mmd
MARKDOWN = docs/readme.md docs/setup.md
# Build targets
build: $(OUT)/document.pdf
# Convert Mermaid to SVG
$(OUT)/%.svg: docs/%.mmd
mkdir -p $(OUT)
mmdc -i $< -o $@
# Combine markdown and SVGs into PDF
$(OUT)/document.pdf: $(MARKDOWN) $(OUT)/architecture.svg $(OUT)/flow.svg
mkdir -p $(OUT)
pandoc docs/readme.md docs/setup.md \
--self-contained-math \
--toc \
--css docs/style.css \
-o $@
clean:
rm -rf $(OUT)
How it works
$(OUT)is a variable holding the output directory path—change it once, not everywhere$(OUT)/%.svg: docs/%.mmdis a pattern rule: “any .svg file in build/ comes from a corresponding .mmd file in docs/”$<is the first dependency (input file),$@is the target (output file)- The PDF target lists all its dependencies: markdown files plus the generated SVG files
- When you run
make build, Make checks if the PDF exists; if not, it regenerates the SVGs from Mermaid, then rebuilds the PDF - If you edit only one markdown file, running
makeagain re-runs Pandoc but re-uses the SVGs (they haven’t changed)
Why it’s useful here
Documentation that includes diagrams needs to stay in sync. If a diagram changes, you have to remember to regenerate it, embed it, and rebuild the PDF. Make does that automatically—change the Mermaid file, run make, and the PDF is fresh. It also only regenerates what changed, so large document builds stay fast.
Where it breaks
Make works when all the files live in your repo and the build process is straightforward. But real documentation is messier. You might pull diagrams from a wiki, import tables from a database, or include code snippets that need updating when the source changes. You might need different PDF layouts for different audiences.
At that point, a documentation-specific tool like Sphinx (Python), Hugo (static sites), or Antora (modular docs) handles more of the problem. They understand doc structure, manage versions, handle search indexing, and integrate with external sources—things Make is too generic to do well. Make is a great stepping stone when your docs are simple, but if you’re maintaining docs as a long-term project, a specialized tool usually saves time.
CSV files in a folder with a set of Python files doing analysis
Bunch of CSV files need analysis re-running, but not enough to warrant Airflow. Make will re-run analysis on your CSV files, regenerating only what has changed.
.PHONY: clean all report
# Input data directory
DATA_DIR = data
# Output directory
RESULTS_DIR = results
# Find all CSV files in data/
CSV_FILES = $(wildcard $(DATA_DIR)/*.csv)
# For each CSV, create a corresponding analysis output file
ANALYSIS_FILES = $(patsubst $(DATA_DIR)/%.csv, $(RESULTS_DIR)/analysis_%.json, $(CSV_FILES))
# Default target
all: report
# Run analysis on a single CSV
$(RESULTS_DIR)/analysis_%.json: $(DATA_DIR)/%.csv scripts/analyze.py
mkdir -p $(RESULTS_DIR)
python scripts/analyze.py $< -o $@
# Aggregated report depends on all analysis files
report: $(RESULTS_DIR)/report.html
$(RESULTS_DIR)/report.html: $(ANALYSIS_FILES) scripts/generate_report.py
python scripts/generate_report.py $(RESULTS_DIR)/analysis_*.json -o $@
clean:
rm -rf $(RESULTS_DIR)
How it works
$(wildcard $(DATA_DIR)/*.csv)finds all CSV files—Make can do basic file globbing$(patsubst from,to,list)transforms paths: it convertsdata/sales.csvtoresults/analysis_sales.json- The pattern rule
$(RESULTS_DIR)/analysis_%.json: $(DATA_DIR)/%.csvsays “for each CSV, create an analysis file” and lists the script as a dependency - When you add a new CSV to
data/, the nextmake reportdiscovers it and automatically runs the analysis - If
analyze.pychanges, all analysis files are regenerated; if only one CSV changes, only that one analysis reruns - The report is regenerated only if any analysis file is newer than the report
Why it’s useful here
Data pipelines often involve dozens of steps on multiple files. Without Make, you either manually rerun commands (and forget which ones), or write a custom script that reruns everything even if nothing changed. Make tracks the actual file timestamps and only reruns what’s needed. It also scales: add a new CSV and the pipeline automatically includes it, no code changes required.
Where it breaks
Make works great for file-based pipelines where you control all the inputs. More complex data systems are messier. What if your data comes from three different databases updated at different times? What if you need to run analysis every Monday at 3 AM whether the data changed or not? What if a downstream team needs notifications when analysis finishes?
Make can’t handle scheduled execution—it only runs when you call it. It also can’t pull from external data sources or coordinate across teams. At that point, Airflow (or Prefect, Dagster, dbt) becomes necessary. These tools handle scheduling, retries, error alerting, cross-system dependencies, and observability—things Make isn’t designed for. Airflow is overkill for “I have a folder of CSVs and four Python scripts,” but it’s the right tool when your pipeline depends on external timing or sources.
Don’t solve problems you don’t have yet
All three examples above have something in common: they start simple with Make, then eventually outgrow it. The key word is “eventually.” Don’t pre-emptively jump to GitHub Actions, Sphinx, or Airflow just because they’re more powerful.
If you have a small Go project, a Makefile is perfect. When you add a second developer and need consistent CI runs, add a pipeline as well. If you’re writing docs for a team project, Make + Pandoc is enough. If you’re building a documentation site that needs versioning and search, then reach for Sphinx.
Make gets you 80% of the way there with 20% of the complexity. Use it. Wait until you actually need the next tool—you’ll know it when you hit Make’s limits.