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

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

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

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.