A comparison of four build systems researched during the design of a new build tool. Each system represents a distinct philosophy and set of tradeoffs.
The original build tool by Stuart Feldman. Make reads a Makefile containing rules that map targets to prerequisites and shell recipes. It uses file modification timestamps to determine what needs rebuilding and supports pattern rules (%.o: %.c) for generic transformations. Make's key weakness is its lack of a real programming language — it has evolved ad-hoc features (conditionals, functions, pattern substitution) that are awkward and error-prone. Recursive make (invoking make in subdirectories) is the standard approach for multi-directory projects, but it fragments the dependency graph, leading to both correctness and performance problems at scale. Despite its limitations, make remains the most widely used build tool due to its ubiquity and simplicity for small projects.
Architecture: Rules map targets to prerequisites with associated shell recipes. The dependency graph is built incrementally — make evaluates rules on-demand as it encounters targets, rather than constructing the full graph first. Recipes are interpreted line-by-line, with each line executed as a separate shell invocation. Built-in implicit rules and suffix rules provide default behavior for common transformations (.c → .o). Variables are expanded using $(VAR) syntax with deferred or immediate evaluation depending on assignment operator (:= vs =).
Pros:
- Ubiquitous — available on every Unix system, deeply embedded in toolchain culture
- Simple mental model for small projects — targets, prerequisites, and recipes are intuitive
- Extensive ecosystem of knowledge, tutorials, and existing Makefiles
- Built-in rules mean trivial C projects need almost no configuration
-j Nparallel execution works well for projects with correct dependencies
Cons:
- Timestamp-only staleness — susceptible to clock skew,
touchfalse positives, and misses when content changes without mtime change - No command string tracking — changing
CFLAGSdoesn't trigger rebuilds - Recursive make fragments the dependency graph, making cross-directory dependencies fragile and slow
- At scale (100k+ files), scanning the entire filesystem to find changes takes minutes
- Line-by-line recipe interpretation creates quoting and escaping headaches
- Pattern rule priority is implicit and silent — ambiguities are resolved by heuristic, not flagged as errors
- Ad-hoc language features (
$(call ...),$(eval ...),.SECONDEXPANSION) are powerful but cryptic
Mk was created by Andrew Hume and Bob Flandrena for the Plan 9 operating system as a cleaner successor to make. It shares make's basic model (targets, prerequisites, recipes) but improves on it in several ways: the full dependency graph is constructed before any recipes execute; recipes are passed verbatim to the shell rather than interpreted line-by-line; and an attribute system (V, Q, N, E, D, P, R) replaces make's magic special targets. Mk has no built-in rules — all defaults are imported via include files, making the tool generic and the conventions explicit. Its metarule system supports % (match anything) and & (match non-/ non-.) patterns with proper ambiguity detection. The P attribute allows fully programmable staleness checks. Mk is deliberately minimal: it delegates all logic to the shell (rc) and focuses on doing dependency-driven execution well.
Architecture: Three-part rule headers (targets:attributes:prerequisites) with recipe bodies passed verbatim to rc(1). The full dependency graph is constructed before execution by computing a transitive closure over metarules to derive all reachable targets, then pruning irrelevant subgraphs. Variables in rule headers resolve at parse time; variables in recipes resolve at execution time (by the shell). The attribute system modifies per-rule behavior: V (virtual/phony), Q (quiet), N (no error if missing), E (continue on error), D (delete target on failure), P (programmable staleness), R (regex metarules). Prototype mkfiles imported via < include replace built-in rules, parameterized by $objtype for Plan 9's heterogeneous architecture support.
Pros:
- Full graph before execution eliminates recursive make's correctness problems
- Attribute system is cleaner and more composable than make's magic target names
- P attribute enables fully programmable staleness — any command can determine if a target is out-of-date
- Recipes passed verbatim to shell — no escaping issues, embedded awk/sed scripts work naturally
$newprereqgives recipes only out-of-date prerequisites, enabling efficient incremental operations&pattern prevents false metarule matches across directory boundaries- D attribute auto-deletes partial targets on failure, preventing stale outputs
- Missing intermediate optimization — won't rebuild
.oif the final binary is current with sources - Ambiguity detection — errors out rather than silently picking one matching metarule
-w fileflag for selective forced rebuilds
Cons:
- Timestamp-only staleness (without P attribute), same weaknesses as make
- No programming language — all logic delegated to the shell, limiting expressiveness within mkfiles
- Transitive closure of metarules can cause rapid graph growth, though pruning keeps it tractable
- Recipes cannot read stdin (mk uses it internally to pipe recipes to the shell)
- Adding a prerequisite to a metarule header can create unintended ambiguities — subtle pitfall
- Tied to rc shell and Plan 9 conventions — less portable than make outside the Plan 9 ecosystem
- No built-in support for header dependency discovery
Jam, created by Christopher Seiwald, is the most language-rich of the systems studied. It has its own interpreted language with procedures (rule), flow control (if/for/while/switch), local variables, and return values — but its only data type is a list of strings. Jam separates rules (procedures that build the dependency graph) from actions (shell commands executed during the update phase), a distinction that no other system makes as cleanly. Its four-phase execution model (startup → parse → bind → update) provides clear boundaries between configuration, name resolution, and execution. Notable features include "grist" for disambiguating same-named targets across directories, SEARCH/LOCATE variables for explicit path resolution during binding, cartesian product variable expansion, piecemeal actions for handling command-line length limits, and INCLUDES for modeling sibling dependencies (header files). Jambase provides all default build rules as a loadable file, keeping the tool itself generic.
Architecture: Four strictly ordered phases — startup (load Jambase and Jamfiles), parsing (evaluate the Jam language to build the dependency graph), binding (resolve target names to filesystem paths using SEARCH/LOCATE), and updating (execute actions in dependency order). Rules are Jam-language procedures that manipulate the dependency graph; actions are separate shell command templates attached to rules. The language has one data type (list of strings) with cartesian product expansion (t$(X) where X="a b c" produces "ta tb tc"), variable modifiers for path decomposition (:D, :B, :S), and dynamic scoping where target-specific variables override globals during that target's processing. Header dependency discovery uses regex scanning (HDRSCAN/HDRRULE) — approximate but dynamic. The standard rule hierarchy follows a delegation pattern: Main/Library → Objects → Object → Cc/C++/Yacc/Lex, with UserObject as the extensibility hook.
Pros:
- Clean separation of rules (graph-building logic) from actions (shell commands) — the key architectural insight
- Four-phase model provides clear boundaries between parsing, name resolution, and execution
- Full interpreted language with procedures, flow control, local variables, and return values
- INCLUDES vs DEPENDS distinguishes direct dependencies from sibling dependencies (header files)
- Binding phase explicitly resolves target names to filesystem paths — no implicit path assumptions
- Grist systematically disambiguates same-named targets across directories
- Piecemeal actions handle command-line length limits transparently
- Cartesian product expansion eliminates boilerplate for common variable patterns
- Jambase externalizes all build conventions — the tool itself is generic
- Modifier rules (ALWAYS, LEAVES, NOCARE, NOTFILE, NOUPDATE, TEMPORARY) provide fine-grained control
- SubDir/SubInclude enables full-graph multi-directory builds
Cons:
- Custom one-off language that only Jam users learn — steep learning curve with no transferable skills
- Timestamp-only staleness — no command string tracking or content hashing
- No command string tracking — changing flags doesn't trigger rebuilds
- Header scanning via regex is approximate — misses conditional includes, computed includes, generated headers
- Dynamic scoping can be confusing — target-specific variable overrides are implicit
- Whitespace sensitivity (missing space between token and
:causes silent bugs) - Action modifiers are limited compared to mk's attribute system
- The language, while richer than make, lacks features expected of a real programming language (no maps, no integers, limited string manipulation)
Tup, created by Mike Shal, prioritizes build correctness above all else. Its defining feature is a persistent dependency DAG stored in a database, which enables O(changed) incremental builds instead of O(total) filesystem scanning. On a project with 100,000 files, tup can determine what to rebuild in 0.1 seconds where make takes 30 minutes. Tup uses runtime file-access monitoring (via FUSE or ptrace) to intercept actual file reads and writes during command execution, catching any undeclared dependencies — this is enforced, not advisory. The system guarantees that N iterations of incremental updates always produce the same result as a clean build. Other notable features include content-aware skipping (don't rebuild dependents if the output didn't actually change), transient outputs (auto-delete intermediate files), and an optional inotify-based monitor for sub-millisecond no-op builds. The tradeoffs are a 5–30% slower initial build, Linux-centric features (FUSE, inotify), and the complexity of maintaining a persistent database.
Architecture: The DAG contains two node types — file nodes and command nodes — with edges representing dependencies. The graph is persisted in a database (SQLite) across builds, enabling change detection proportional to what actually changed rather than the total project size. The update algorithm scans the filesystem for modifications (or receives inotify notifications), marks affected nodes in the DAG, and executes independent commands in parallel respecting dependency ordering. During execution, tup intercepts file system calls to verify that every file read was declared as an input and every file written was declared as an output — violations are errors. Only one command may write to a given output file (enforced). Variants are supported through separate directories each with their own tup.config, with @-variables reading configuration values and establishing dependencies on them.
Pros:
- Persistent DAG enables O(changed) incremental builds — near-instant when nothing changed
- Runtime file-access monitoring guarantees dependency completeness — no hidden dependencies possible
- Correctness guarantee: N iterations of updates always produce the same result as a clean build
- No clean target needed — the database tracks all generated files and auto-deletes stale ones
- Content-aware skipping (
^o) avoids rebuilding dependents when output is unchanged - Transient outputs (
^t) automatically delete intermediate files after dependents complete - inotify monitor mode eliminates filesystem scanning entirely — sub-millisecond no-op updates
- File + command node DAG naturally models multi-output commands
- Variant builds via separate config directories, all updated in parallel
- One command per output file — enforced, preventing conflicting rules
Cons:
- 5–30% slower on initial/clean builds (database overhead)
- Runtime file-access interception requires FUSE or ptrace — Linux-centric and complex
- FUSE requires elevated privileges or user namespaces on some systems
- inotify monitor is Linux-only
- No programming language in Tupfiles — limited to a DSL with variables and macros
- Persistent database adds complexity (corruption recovery, migration between versions)
- Cannot easily integrate with build systems that don't go through tup (external build steps)
- The
runcommand for dynamic rule generation is powerful but opaque
Ninja, created by Evan Martin for the Chromium project, is built around a single insight: "build systems get slow when they need to make decisions." Ninja is deliberately not designed to be written by hand — it's a "build assembler" meant to be generated by higher-level tools (CMake, Meson, GN). By eliminating all policy decisions from the build execution phase, Ninja achieves sub-second incremental builds on projects with 30,000+ files. Commands implicitly depend on their own command string, so changing compiler flags automatically triggers rebuilds with no special mechanism. Ninja distinguishes three dependency types (explicit, implicit, order-only) and integrates directly with compiler-generated depfiles for header tracking. It runs parallel by default, buffers output per-job to prevent interleaving, and uses pools to throttle resource-heavy operations. Other practical features include response files for Windows command-line limits, self-regeneration (rebuilds its own build file if stale), and a console pool for interactive tools. Ninja's deliberate minimalism makes it fast and reliable but requires a generator frontend for usability.
Architecture: rule declarations define named command templates with variable placeholders; build statements declare specific file transformations using those rules. Variables are immutable bindings that can only be shadowed in narrower scopes — build-level shadows rule-level shadows file-level. Scoping lookup order: built-ins ($in, $out) → build statement → rule (expanded late) → file-level → parent scope. Three dependency types: explicit (listed in $in, missing is fatal), implicit (| dep — triggers rebuild but not in $in, missing is non-fatal), and order-only (|| dep — must exist before running but changes don't trigger rebuild). Header dependencies are stored compactly in .ninja_deps (binary log) after being read from compiler-generated depfiles. Dynamic dependencies (dyndep, v1.10) allow the build graph to be augmented mid-build from generated files. The pool mechanism limits concurrency for specific rules, with a special console pool (depth 1) providing direct terminal access. Self-regeneration: if build.ninja is itself a build output and is stale, ninja rebuilds and reloads it before proceeding.
Pros:
- Extremely fast incremental builds — sub-second on 30,000+ file projects
- Command string as implicit dependency — flag changes trigger rebuilds with zero extra mechanism
- Three dependency types precisely model real-world relationships (explicit, implicit, order-only)
- Compiler depfile integration is exact and avoids fragile scanning heuristics
- Dynamic dependencies (dyndep) enable correct first builds when deps are only discoverable at build time
- Parallel by default — underspecified deps surface as failures instead of hiding behind serial execution
- Buffered per-job output prevents interleaving; on failure, full command + output shown together
- Pools with depth limits for throttling resource-heavy operations (linking, license-limited tools)
- Console pool grants stdin/stdout access for interactive tools
- Response files handle Windows command-line length limits
- Self-regeneration elegantly handles build file changes
- GNU jobserver integration for nested build systems
- Immutable variables prevent accidental mutation bugs
Cons:
- Deliberately not designed for humans — requires a generator frontend (CMake, Meson, GN)
- No programming language, no conditionals, no loops — all logic must be in the generator
- No built-in understanding of any toolchain — everything must be spelled out
- Phony rule semantics can be surprising (inputs "passed through" to dependents)
subninjascoping rules add complexity for multi-file builds.ninja_depsbinary log is an opaque format that can't be inspected or edited- No content-based skipping by default —
restatmust be opted into per-rule - No built-in clean mechanism — requires the generator to produce clean rules
| Make (baseline) | Plan 9 mk | Perforce Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Year | 1976 | 1984 | 1993 | 2009 | 2012 |
| Language | Makefile DSL | Mkfile DSL + rc shell | Custom interpreted language | Tupfile DSL | Ninja DSL |
| Philosophy | General-purpose | Minimal, shell-centric | Programmable build logic | Correctness above all | Build assembler (generated, not hand-written) |
| Built-in rules | Yes | No (imported via includes) | No (provided by Jambase) | No | No |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Graph construction | Incremental (on-demand) | Full graph before execution | Full graph before execution (4-phase) | Persistent DAG in database | Full graph before execution |
| Node types | File targets | File targets | File targets | File nodes + command nodes | File targets |
| Cycle handling | Allowed | Prohibited | Prohibited | Prohibited | Prohibited |
| Pattern/metarules | %.o: %.c |
% and & metarules with transitive closure |
Suffix-based dispatch via rules | None (explicit rules only) | None (generator handles it) |
| Ambiguity | Silent priority-based choice | Error on ambiguity | Error on ambiguity | N/A | N/A |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Primary method | Timestamps | Timestamps | Timestamps | Content-based (database) | Timestamps + command string hash |
| Command string tracking | No | No | No | Yes | Yes (implicit dep on command) |
| Custom staleness | No | P attribute (programmable) | No | No | No |
| Content awareness | No | No | No | Yes (skip if output unchanged) | restat mode |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Method | External (makedepend) or -M flags |
Manual or external | Regex scanning (HDRSCAN/HDRRULE) | Runtime file-access interception | Compiler depfiles (-M flags) |
| Accuracy | Depends on method | Manual | Approximate (over-includes) | Exact | Exact |
| Portability | Good | Good | Good | Linux-centric (FUSE/ptrace) | Good |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Default | Serial | Parallel ($NPROC) |
Serial (-j) |
Parallel (from DAG) | Parallel (CPU count) |
| Output handling | Interleaved | Interleaved | Interleaved | Buffered | Buffered per-job |
| Throttling | -j N |
$NPROC |
-j N |
N/A | Pools with depth limits |
| Process slot ID | No | $nproc per-job |
! in JAMSHELL |
No | No |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Recipe handling | Line-by-line interpreted | Passed verbatim to shell (rc) | Passed to shell via JAMSHELL | Monitored shell execution | Passed to shell |
| Recipe stdin | Available | Not available (mk uses it) | Available | Available | Available (console pool) |
| Multi-output rules | Awkward (grouped targets) | Aliases | Actions attached to rules | Natural (command node → multiple outputs) | Implicit outputs (| out) |
| Action modifiers | None | Attributes (V, Q, N, E, D, P, R) | quietly, piecemeal, existing, bind |
Flags (^b, ^o, ^t, ^s, etc.) |
generator, restat, depfile |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Data types | Strings | Strings | Lists of strings | Strings | Strings |
| Variable expansion | $(VAR) |
$VAR, ${var:A%B=C%D} transforms |
$(VAR) with cartesian product, :D/:B/:S modifiers |
$(VAR), @(CONFIG) |
$var, ${var} |
| Flow control | $(if ...), $(foreach ...) |
None (delegated to shell) | if/for/while/switch |
None | None |
| Scoping | Global + target-specific | Headers at parse-time, recipes at exec-time | Global + target-specific (dynamic scoping) | File-scoped | File-scoped, shadowing only |
| Procedures | None | None (shell functions) | rule definitions |
None | rule templates |
| Mutability | Mutable | Mutable | Mutable | Mutable | Immutable (bindings, shadow only) |
| Make | mk | Jam | Tup | Ninja | |
|---|---|---|---|---|---|
| Approach | Recursive make (SUBDIRS) | Include (<) |
SubDir/SubInclude with grist | Auto-discovery of Tupfiles per directory | subninja/include |
| Graph visibility | Partial (per-invocation) | Full (single process) | Full (single process) | Full (persistent database) | Full (single process) |
| Target disambiguation | Path-based | Path-based | Grist (<dir!subdir>file) |
Path-based | Path-based |
| Cross-directory deps | Fragile | Robust | Robust (SEARCH/LOCATE) | Robust | Robust |
- Attribute system replaces make's magic targets with explicit per-rule flags (V, Q, N, E, D, P, R)
- P attribute enables fully programmable staleness detection — any external command can determine if a target is out-of-date
$newprereqprovides only out-of-date prerequisites to recipes, enabling efficient incremental operations (e.g. archive updates)&pattern (matches non-/non-.) prevents false metarule matches across directory boundaries- Missing intermediate optimization — won't rebuild
.ofiles if the final binary is current with original sources - D attribute auto-deletes partial targets on recipe failure, preventing stale outputs from persisting
- Four-phase execution (startup → parse → bind → update) cleanly separates concerns
- INCLUDES vs DEPENDS — distinguishes direct dependencies from sibling dependencies (header inclusion)
- Binding phase with SEARCH/LOCATE explicitly resolves target names to filesystem paths
- Cartesian product variable expansion (
t$(X)→ta tb tc) eliminates explicit loops for common patterns - Piecemeal actions automatically chunk large file lists to avoid command-line length limits
- Grist provides a systematic solution to same-name target disambiguation across directories
- Full interpreted language with procedures, flow control, local variables, and return values
- Persistent DAG in a database enables O(changed) incremental updates instead of O(total) scanning
- Runtime file-access monitoring catches undeclared dependencies — one command per output enforced, not just by convention
- No clean target needed — the database tracks all generated files; N updates always equals a clean build
- Content-aware skipping (
^oflag) avoids rebuilding dependents when output is unchanged - Transient outputs (
^tflag) automatically delete intermediate files after dependents complete - inotify monitor mode (Linux) eliminates filesystem scanning entirely — sub-millisecond no-op updates
- "Build systems get slow when they need to make decisions" — eliminating decisions at build time is the core insight
- Command string as implicit dependency — changing flags automatically triggers rebuilds with no special tracking
- Three dependency types (explicit, implicit, order-only) precisely model different dependency relationships
- Compiler depfile integration stores header dependencies compactly in
.ninja_deps, avoiding reparsing - Dynamic dependencies (dyndep) enable correct first builds when dependencies are only discoverable by analyzing sources
- Self-regeneration — rebuilds and reloads its own build file if stale
- Response files transparently handle Windows command-line length limits
- Console pool provides direct terminal access for interactive or status-printing tools
| Tradeoff | Conservative choice | Aggressive choice |
|---|---|---|
| Correctness vs speed | mk/make: timestamps only, simple | Tup: content hashing + runtime interception, always correct |
| Simplicity vs features | Ninja: deliberately minimal, generated not written | Jam: full language, rich feature set |
| Portability vs power | Ninja/Jam: pure userspace, works everywhere | Tup: OS-specific (FUSE, inotify) for maximum correctness |
| Convenience vs explicitness | Make: built-in rules, implicit behavior | mk/Ninja: no built-in rules, everything explicit |
| Initial build vs incremental | Make: fast initial, slow incremental at scale | Tup: 5–30% slower initial, near-instant incremental |
| Hand-written vs generated | mk/Jam: designed for humans | Ninja: designed for machines |
-
Separate the language from the engine. Jam's split between rules (logic) and actions (shell commands) is the right model. Make's conflation of the two causes most of its problems.
-
Build the full graph before executing anything. Every system except make does this. Partial graph visibility (recursive make) is both slow and incorrect.
-
No built-in build knowledge. mk, Jam, and Ninja all externalize their default rules. The build tool should be a generic engine; build conventions belong in loadable configuration.
-
Command strings are dependencies. Ninja's insight that changing flags should trigger rebuilds — with no special mechanism beyond hashing the command — is elegant and should be standard.
-
Timestamps alone are insufficient. Every modern system supplements or replaces timestamps. Content hashing (cached behind stat fingerprints) provides correctness without excessive cost.
-
Let the compiler track headers. Depfiles (
-Mflags) are exact and portable. Regex scanning (Jam) is fragile. Runtime interception (tup) is correct but not portable. Depfiles are the pragmatic choice. -
Parallel by default. Ninja's approach of defaulting to parallel execution and surfacing broken dependencies as failures is better than make's serial-by-default which hides dependency bugs.
-
Pattern rules are a workaround. Make and mk need patterns because they lack a programming language. With a real language for build descriptions, iteration and dispatch are just code.
Claude Code wrote this. I thought it was a neat experiment to have it research something.