Build Systems Overview

A build system turns source code into artifacts — object files, jars, wheels, binaries, container images — by running an ordered set of commands. The decisive difference between a primitive build system and a modern one is whether it models the build as a graph of tasks with declared inputs and outputs, because only a graph-aware system can answer the question that dominates every real build: given what just changed, what is the minimal set of work I actually have to redo? The oldest tool, GNU Make, answers it crudely with file timestamps; the modern generation — Bazel, Gradle, Nx, Pants, Buck2 — answers it precisely by hashing declared inputs, which is what makes their results cacheable, parallelizable, and shareable across a whole team (Bazel Hermeticity). This note maps that landscape: how a task/dependency graph is constructed, the split between imperative build scripts and declarative build graphs, and the concrete algorithm a graph-aware tool uses to compute the minimal rebuild set.

The single idea to carry out of this note: a build tool is a graph engine, and everything good about a modern build — incrementality, caching, remote execution, correctness — falls out of how faithfully that graph captures the true inputs of each task. A tool that under-declares inputs is fast but wrong (it skips work it should have done); a tool that over-declares is correct but slow (it redoes work needlessly). The art of every system below is declaring inputs exactly.

Mental Model — The Build Is a Directed Acyclic Graph

Think of a build as a directed acyclic graph (DAG): nodes are units of work (compile this file, link this binary, run this test), and edges are dependencies (the link step depends on the compile step’s output). The tool’s job is to (1) construct this graph, (2) topologically order it so a node runs only after its dependencies, and (3) execute the smallest possible subset of nodes needed to bring the requested outputs up to date. Every build tool in this note is a variation on that theme; they differ in how they build the graph, how they decide a node is stale, and how far they go to reuse prior results.

flowchart TD
    subgraph SRC["Source inputs (leaves)"]
        A["main.c"]
        B["util.c"]
        H["util.h"]
    end
    subgraph ACT["Intermediate actions"]
        MO["compile main.o<br/>inputs: main.c, util.h"]
        UO["compile util.o<br/>inputs: util.c, util.h"]
    end
    OUT["link app<br/>inputs: main.o, util.o"]

    A --> MO
    H --> MO
    B --> UO
    H --> UO
    MO --> OUT
    UO --> OUT
    OUT --> RUN["run tests<br/>inputs: app"]

What it shows and the insight to take: the graph makes dependencies explicit and directional. If util.h changes, the tool follows edges forward (downstream) to discover exactly which nodes are now stale — util.o, main.o, then app, then the tests — and rebuilds only those. If only main.c changes, util.o is untouched and is not rebuilt. The whole value proposition of a build system is compressing “rebuild everything” into “rebuild the forward-reachable set of what changed.” The quality of that compression is bounded entirely by how honestly each node declares its inputs — the reason util.h must appear as an edge into both compile actions.

Imperative Build Scripts versus Declarative Build Graphs

The foundational fault line in build tooling is imperative script versus declarative graph, and it is worth being precise because the words get abused.

An imperative build script is a list of commands the author writes to perform the build: “run the compiler on these files, then run the linker, then copy the output here.” A raw shell script is the purest form. The author is responsible for ordering, for knowing what depends on what, and — critically — for deciding what can be skipped. The tool executes the steps; it has no independent model of why a step exists or what it truly consumes.

A declarative build graph inverts this: the author describes the desired outputs and their dependencies — “this binary is produced from these sources and these libraries” — and the tool derives the execution plan. In Bazel this description lives in BUILD files written in Starlark, where each target “specifies a set of input artifacts that Bazel will build plus their dependencies, the build rule Bazel will use to build it, and options that configure the build rule” (Bazel Intro). The author never writes “compile then link”; they declare cc_binary(name="app", srcs=[...], deps=[...]) and Bazel’s analysis phase computes the compile and link actions and their ordering.

The consequence is not stylistic — it is what makes correctness and caching possible:

  • With an imperative script, the tool cannot know whether a step is safe to skip, because it does not know the step’s true inputs. It must either rerun everything (slow) or trust the author’s hand-written skip logic (fragile).
  • With a declarative graph, the tool owns the input/output relationship, so it can hash inputs, cache outputs against those hashes, and skip any node whose inputs are unchanged — with a correctness guarantee the author never has to reason about.

GNU Make sits between these poles and illustrates the gradient. A Makefile is declarative about dependencies (app: main.o util.o states the edges) but imperative about recipes (the tab-indented commands). Make owns the graph but not the semantics of the commands — which is exactly why its staleness check is limited to timestamps.

flowchart LR
    subgraph IMP["Imperative script"]
        direction TB
        I1["author writes<br/>ordered commands"]
        I2["tool executes<br/>steps blindly"]
        I3["skip logic is<br/>hand-written / absent"]
        I1 --> I2 --> I3
    end
    subgraph DEC["Declarative graph"]
        direction TB
        D1["author declares<br/>outputs + deps"]
        D2["tool derives<br/>the action graph"]
        D3["tool hashes inputs,<br/>caches + skips safely"]
        D1 --> D2 --> D3
    end
    IMP -. "correctness &amp; caching<br/>are the author's problem" .-> X["fragile / slow"]
    DEC -. "correctness &amp; caching<br/>are the tool's problem" .-> Y["cacheable / parallel / reproducible"]

What it shows and the insight to take: the arrow of responsibility moves from author to tool as you go declarative. That shift is why only declarative graph tools can offer trustworthy caching and remote execution — the tool, not the human, is the authority on what each node consumes.

How a Graph-Aware Tool Computes the Minimal Rebuild Set

This is the mechanical heart of the note. There are two families of staleness detection, and the difference between them is the difference between Make and everything modern.

Timestamp-based invalidation (Make)

GNU Make decides a target is out of date by comparing file last-modification times. From the manual: “The recompilation must be done if the source file, or any of the header files named as prerequisites, is more recent than the object file, or if the object file does not exist” (GNU Make, How Make Works). Concretely, Make walks the dependency graph, and for each target with prerequisites it asks: does the target file exist, and is its mtime ≥ the mtime of every prerequisite? If yes, the target is up to date and its recipe is skipped; if no, the recipe runs. This cascades: rebuilding util.o updates its mtime, which then makes app older than its prerequisite, triggering the link.

Timestamp invalidation is simple and fast but has real failure modes: a clock skew or a touch can make Make skip needed work or redo unneeded work; it cannot detect that a file’s content is unchanged even though its mtime moved (e.g., git checkout rewrites mtimes); and it has no notion of the command changing — swap a compiler flag and Make happily reuses stale objects because the timestamps did not move.

Content/input-hash invalidation (Bazel, Gradle, Nx, Pants, Buck2)

Modern tools replace “is it newer?” with “is the hash of everything this action depends on the same as last time?” The cache key for a node is a hash of its declared inputs — source file contents, tool versions, compiler flags, environment, and the identities of upstream outputs. Bazel “caches all previously done work and tracks changes to both file content and build commands” (Bazel Intro); its remote cache is literally “a map of action hashes to action result metadata” plus a content-addressable store of the outputs (Bazel Remote Caching). Gradle does the equivalent with declared task inputs and outputs: it “snapshots these during execution to detect changes” and marks a task UP-TO-DATE when its inputs are unchanged (Gradle Build Lifecycle). Nx “generates a hash from source files, configuration, and dependencies” and, on a matching hash, “retrieve[s] cached results — including outputs and artifacts — without rerunning work” (Nx Mental Model).

The algorithm, generalized across all of them:

flowchart TD
    START["Request: build target T"] --> GRAPH["Construct action graph<br/>for T and its transitive deps"]
    GRAPH --> TOPO["Topologically order nodes"]
    TOPO --> NODE{"For each node in order:<br/>compute cache key =<br/>hash(inputs + command + deps' outputs)"}
    NODE --> HIT{"key in cache?"}
    HIT -->|"yes"| REUSE["Reuse cached output<br/>(local or remote) — skip execution"]
    HIT -->|"no"| RUN["Execute action"]
    RUN --> STORE["Store output under key<br/>in local + remote cache"]
    REUSE --> NEXT["Feed output hash<br/>to downstream nodes"]
    STORE --> NEXT
    NEXT --> DONE{"more nodes?"}
    DONE -->|"yes"| NODE
    DONE -->|"no"| OUT["T is up to date"]

What it shows and the insight to take: the minimal rebuild set is computed, not configured. Because each node’s key folds in the output hashes of its dependencies, a change ripples forward automatically — the first changed node gets a new key, which changes its output, which changes the key of everything downstream, and unchanged branches keep their old keys and are served from cache. This is strictly more precise than timestamps: it detects command/flag changes (they are in the key), ignores no-op mtime changes (content hashes are identical), and — the big payoff — a cache hit from a teammate’s machine is valid, because the key is a pure function of inputs, not of local file times. “If your build is reproducible, the outputs from one machine can be safely reused on another machine” (Bazel Remote Caching). That is the entire basis of remote caching and remote execution — and it works only because the graph declares inputs honestly, i.e., the build is hermetic (see Hermetic and Reproducible Builds).

The Tools, Concretely

GNU Make — the imperative-recipe ancestor

Make is the archetype: a Makefile declares targets, prerequisites, and recipes, and Make rebuilds a target when it “does not exist, or if any of the object files are newer than it” (GNU Make). It owns the dependency DAG and does correct topological ordering and parallelism (make -j), but its staleness model is timestamps and its recipes are opaque shell. It has no built-in content hashing, no shared cache, no sandboxing, and no automatic input discovery — you must hand-write header dependencies (or generate them with gcc -M). Make remains ubiquitous for small projects and as the lowest-common-denominator entry point (“just run make”), but it does not scale to a large heterogeneous codebase where honest input tracking matters.

# Targets, prerequisites, recipes — declarative deps, imperative commands
app: main.o util.o          # 'app' depends on both objects
	cc -o app main.o util.o  # recipe: run only if app is stale
main.o: main.c util.h        # header listed by hand — Make can't infer it
	cc -c main.c
util.o: util.c util.h
	cc -c util.c

The load-bearing fragility is on line 4’s comment: if you forget to list util.h as a prerequisite of main.o, Make will not rebuild main.o when the header changes, and you get a silently stale binary. The tool cannot catch this because it does not understand the recipe.

Bazel — the declarative, hermetic, phased graph

Bazel (Google’s open-sourced Blaze) is the canonical declarative build graph. It runs in three phases: a loading phase reads BUILD files, an analysis phase “analyzes the inputs and their dependencies, applies the specified build rules, and produces an action graph,” and an execution phase runs the actions (Bazel Intro). Its correctness rests on hermeticity: “when given the same input source code and product configuration, a hermetic build system always returns the same output by isolating the build from changes to the host system,” achieved by treating tools as versioned dependencies and enabling “strict sandboxing at the per-action level” so actions cannot read undeclared files (Bazel Hermeticity). Hermeticity is precisely what unlocks the four downstream wins Bazel lists — caching, parallelization, reproducibility, and multi-target builds — and enables remote caching and remote build execution across a worker fleet (see Build Caching Local and Remote, Remote Build Execution). Bazel’s cost is up-front rigor: you must declare dependencies explicitly and fit your toolchains into its sandbox.

Gradle — declarative DAG with imperative escape hatches

Gradle (dominant in the JVM/Android world) builds a task DAG during its configuration phase, before executing anything: “Gradle builds the task graph before executing any task(s)” across its three phases — initialization, configuration, execution (Gradle Build Lifecycle). Each task declares typed inputs and outputs; Gradle snapshots them and skips tasks marked UP-TO-DATE, and its build cache can “cache task outputs and reuse them across builds, bypassing execution entirely when inputs haven’t changed.” Gradle is more flexible (and more imperative) than Bazel — build logic is arbitrary Groovy/Kotlin code — which is powerful but makes it easier to write a task that under-declares its inputs and thus caches incorrectly. Its incrementality is genuine input-hash invalidation, not timestamps.

Nx — the JavaScript/TypeScript monorepo graph

Nx models the repo at two graph levels. The project graph is inferred from the filesystem — Nx detects projects (by package.json/project.json) and their dependencies by reading imports and installed packages. The task graph is derived from the project graph but is not isomorphic to it: a project dependency does not force a task dependency, so testing app1 need not first test a library it depends on, freeing tasks to run in parallel (Nx Mental Model). Task dependencies are declared with dependsOn (e.g., "dependsOn": ["^build"] means “build my dependencies first”). Nx’s headline feature is nx affected: it “uses Git to determine the files you changed,” maps files to projects via the project graph, finds “which projects depend on the projects you modified,” and runs the requested tasks only on that subset (Nx Affected) — comparing a --base (typically the last successful main commit) against --head. This is affected-target detection applied to CI, the central mechanism explained in Monorepo versus Polyrepo.

Pants — dependency inference over the graph

Pants targets large multi-language repos (Python, Go, Java, Scala, Kotlin, Shell) and its distinguishing choice is dependency inference via static analysis instead of handwritten metadata (Pants Welcome). Where Bazel makes you enumerate deps=[...] by hand, Pants reads your import statements and infers the edges, so it “doesn’t require you to refactor your codebase or to create and maintain massive amounts of build metadata.” Its engine is written in Rust and coordinates work as typed async rules, with “fine-grained invalidation and shared result caching” plus “concurrent and remote execution.” The trade-off: inference is convenient but can be surprising when it guesses an edge wrong or misses a dynamic import, whereas Bazel’s explicitness is verbose but unambiguous.

Buck2 — Meta’s Rust rewrite, remote-execution-first

Buck2 (Meta’s successor to Buck, itself contemporary with Bazel) shares the Starlark-rules design — “the binary itself is entirely language agnostic” because all rules live in Starlark, not the core (Buck2 Why). Two design points distinguish it: it is “remote execution first,” treating local execution as the special case, and it is not phased — unlike Bazel’s separate target/analysis and execution phases, Buck2 uses “a dynamic (aka monadic) graph as its underlying computation engine,” letting rules examine file contents before declaring further dependencies (dynamic dependencies). Meta reports Buck2 completing builds “2x” faster than Buck1 internally. It is the state of the art for a very large hermetic monorepo but is heavier to adopt than a JS-focused tool like Nx.

Comparison

ToolGraph modelStaleness detectionInput declarationCachingBest fit
GNU MakeTarget/prereq DAG, imperative recipesFile mtime comparisonHand-written prerequisitesNone built inSmall projects; universal entry point
BazelDeclarative BUILD targets → action graph, phasedContent + command hashExplicit depsLocal + remote + remote executionLarge hermetic polyglot monorepos
GradleTask DAG built at config timeInput/output snapshot hashTyped task inputs/outputs (+ imperative logic)Local + remote build cacheJVM / Android
NxProject graph + task graphHash of sources/config/depsInferred imports + dependsOnLocal + remote (computation cache)JS/TS monorepos
PantsRule graph (Rust engine)Fine-grained input hashInferred by static analysisLocal + remoteMulti-language repos wanting low metadata
Buck2Dynamic (monadic) single graphContent hash, dep-filesExplicit Starlark, dynamic depsRemote-execution-firstVery large hermetic monorepos

What it shows and the insight to take: the axis that matters is how honestly and automatically inputs are declared, because that gates the caching column. Make can’t cache across machines because it has no content key; the modern tools can, and they differ mainly in who declares the edges — the human (Bazel, Buck2), the tool by inference (Pants, Nx), or a hybrid (Gradle).

Common Misunderstandings and Failure Modes

“Make is incremental, so it’s basically the same as Bazel.” No. Make is incremental by timestamp, which is a strictly weaker and less correct signal than an input-content hash. Make cannot detect a flag change, is fooled by touch/git checkout mtime rewrites, and cannot share results across machines. The gap between mtime and content-hash invalidation is the gap between a single-developer convenience and a team-scale build cache.

Under-declared inputs cause silent staleness. Every graph tool is only as correct as its input declarations. If an action reads a file it did not declare (a stray header, an ambient environment variable, a network fetch), the tool may cache a result that is actually wrong and serve it forever. This is why Bazel pushes sandboxing so hard — the sandbox makes an undeclared read fail loudly instead of silently poisoning the cache (Bazel Hermeticity). Non-hermetic actions are the number-one source of “works on my machine, cache hit gave the wrong binary” bugs; see Hermetic and Reproducible Builds.

Cache poisoning from non-determinism. If an action’s output is not a pure function of its declared inputs — it embeds a timestamp, a hostname, or iterates a hashmap in random order — then two runs with the same key produce different bytes, and the shared cache serves whichever it stored first. Reproducibility (SOURCE_DATE_EPOCH, sorted inputs, zeroed timestamps) is a precondition for a trustworthy cache, which is why Build Reproducibility and caching are the same conversation.

Over-declaration is a performance bug, not a correctness bug. Declaring deps too coarsely (e.g., one giant target instead of fine-grained ones) is safe but defeats incrementality — any change invalidates the whole blob. The skill in a large Bazel/Pants repo is keeping targets small so the forward-reachable stale set stays small.

When to Choose Which

Choose Make for a small project, a thin wrapper around other tools, or a universal make build entry point — its ubiquity is its feature; do not reach for it as the backbone of a large multi-team codebase. Choose Gradle when you are in the JVM/Android ecosystem where it is the default and its plugin ecosystem pays for itself. Choose Nx for a JavaScript/TypeScript monorepo where inferred project graphs and affected on top of your existing npm/pnpm workspace get you most of the benefit with little config. Choose Pants for a multi-language (especially Python-heavy) repo where you want graph-aware caching without writing and maintaining explicit dependency metadata. Choose Bazel or Buck2 when you have a very large, polyglot, multi-team monorepo and need airtight hermeticity, remote caching, and remote execution — accepting the substantial up-front cost of explicit dependencies and toolchain sandboxing. The decision is coupled to the Monorepo versus Polyrepo question: a graph-aware tool is mandatory for a monorepo and merely nice for a polyrepo of small independent services.

See Also