Skip to content

Ninja (C/C++/native)

A deliberately minimal, maximally-fast build executor — "an assembler for build systems" — that consumes a machine-generated build.ninja graph and runs the commands needed to bring it up to date, with near-instant incremental rebuilds at Chromium scale.

FieldValue
LanguageC++ (C++11; a ~10k-line single binary, no runtime dependencies)
LicenseApache-2.0 ("Copyright 2011 Google Inc.")
Repositoryninja-build/ninja
DocumentationNinja manual · ninja-build.org
CategoryNative Build System
Workspace modelSingle generated build.ninja per output tree; no member list — the graph is the workspace, composed via subninja/include
First releasedNinja 1.0 in 2012 (Evan Martin, for the Chromium browser; first commit 2010)
Latest releasev1.13.2 (stable); main is 1.14.0.git (src/version.cckNinjaVersion = "1.14.0.git")

Latest release (as of June 5, 2026): the current stable line is v1.13.2 (the 1.13.0 release of June 2025 landed GNU Make jobserverclient support after nine years of proposals; 1.13.1/1.13.2 are bug-fix point releases). The development tree on main reports 1.14.0.git (src/version.cc). Ninja follows major.minor.patch where "the major version is increased on backwards-incompatible syntax/behavioral changes and the minor version is increased on new behaviors" (manual); a build file can pin a floor with ninja_required_version = 1.13.


Overview

What it solves

Ninja exists because the edit-compile cycle on huge C/C++ trees was slow to start — not slow to compile, slow to decide what to compile. From the manual's introduction (doc/manual.asciidoc):

"It is born from my work on the Chromium browser project, which has over 30,000 source files and whose other build systems (including one built from custom non-recursive Makefiles) would take ten seconds to start building after changing one file. Ninja is under a second."

The design conclusion was radical: strip the build executor of every feature that requires it to "make decisions" at build time, push all of that work into a separate generator program (run once, up front), and leave Ninja with only the barest machinery to describe and walk a dependency graph. Ninja is therefore almost never invoked directly by a human writing build files; it is the backend for CMake, Meson, GN, Kati (Android), premake, and many others — they emit build.ninja, Ninja runs it. The two-layer generator+executor split, viewed from the generator side, is covered in the GN + Ninja deep-dive; this page is about the executor itself — its file format, change-detection, scheduler, and the on-disk logs that make restart cheap.

Within the survey, Ninja is the canonical "dumb fast executor" data point: contrast the hermetic, content-addressed, package-managing engines Bazel/Buck2/Pants, the sibling native generators CMake/Meson/SCons that target Ninja, and the minimalist research tools redo/tup that Evan Martin explicitly credits as influences.

Design philosophy

The manual states the thesis in one sentence (manual):

"Where other build systems are high-level languages, Ninja aims to be an assembler."

and the operative rule that follows from it:

"Build systems get slow when they need to make decisions. … when convenience and speed are in conflict, prefer speed."

The design goals are correspondingly austere: "very fast (i.e., instant) incremental builds, even for very large projects"; "very little policy about how code is built"; and "get dependencies correct, and in particular situations that are difficult to get right with Makefiles." The non-goals are the revealing part (manual):

"convenient syntax for writing build files by hand. You should generate your ninja files using another program. … built-in rules. Out of the box, Ninja has no rules for e.g. compiling C code. … build-time decision-making ability such as conditionals or search paths. Making decisions is slow."

So there is no if, no globbing, no functions, no search paths, no package manager, no toolchain detection — none of it. A .ninja file is an intermediate representation, like an object file: legible enough to debug, but written by a machine. The whole tool is ~10k lines of dependency-free C++ that compiles to a single static binary, deliberately small enough to vendor.


How it works

The file format: rule + build

A build.ninja file has exactly two load-bearing constructs (manual): a rule (a named command template) and a build statement (one edge of the DAG binding outputs to a rule and inputs).

ninja
cflags = -Wall

rule cc
  command = gcc $cflags -c $in -o $out

build foo.o: cc foo.c

$in expands to the edge's inputs, $out to its outputs. "Conceptually, build statements describe the dependency graph of your project, while rule statements describe how to generate the files along a given edge." Variables are immutable bindings ("a given variable cannot be changed, only shadowed") and expand immediately — except inside a rule, where they expand late, when the rule is used, so $in/$out and per-edge shadows resolve correctly.

The full edge syntax encodes the three dependency flavors and (since 1.7) implicit outputs in punctuation:

ninja
build out1 out2 | implicit_out : rulename in1 in2 | implicit_in || order_only_in |@ validation
TokenMeaning
out1 out2Explicit outputs — appear in $out
| implicit_outImplicit outputs (1.7+) — built, but not in $out
in1 in2Explicit inputs — appear in $in; a change rebuilds the output, a missing one aborts the build
| implicit_inImplicit inputs — same dirty semantics as explicit, but not in $in (e.g. a script's hardcoded file)
|| order_only_inOrder-only inputs — must be built first, but a change in them alone does not trigger a rebuild
|@ validationValidations (1.11+) — pulled into the build whenever the edge is, but their state never dirties it

Other declaration types: variable assignments, default target… (the targets built when none are named), pool name, and the two file-composition keywords subninja path / include path (below).

Rule attributes that matter for monorepos

A handful of rule keys (manual § Rule variables) carry the real semantics:

  • depfile — path to a Makefile-syntax dependency file the command writes (gcc -MD -MF $out.d). Ninja reads it to pick up header dependencies it could not know statically.
  • deps = gcc / deps = msvc (1.3+) — instead of re-reading .d files on every startup (slow, "particularly on Windows, where the file system is slow"), Ninja parses the compiler's dependency output once, immediately after the command finishes, and folds it into a compact binary database (.ninja_deps), deleting the .d. msvc parses cl /showIncludes stdout with a configurable msvc_deps_prefix.
  • restat"causes Ninja to re-stat the command's outputs after execution. Each output whose modification time the command did not change will be treated as though it had never needed to be built" — pruning the downstream rebuild cascade when a regenerated file is byte-identical. (Generators set this on codegen edges.)
  • generator — marks the rule that re-runs the meta-build; its outputs are "not rebuilt if the command line changes" and not cleaned by default.
  • rspfile / rspfile_content — write a response file before the command (Windows command-line-length workaround for huge link lines).
  • pool — cap this rule's concurrency (below).

Change detection: mtime + a command hash, not content hashing

Ninja "evaluates a graph of dependencies between files, and runs whichever commands are necessary to make your build target up to date as determined by file modification times" (manual). The dirty check, in DependencyScan::RecomputeOutputDirty (src/graph.cc), is a short ladder. An output is rebuilt if:

  1. it is missing;

  2. its mtime is older than the newest inputoutput->mtime() < most_recent_input->mtime() (src/graph.cc:335);

  3. the command line changedNinja hashes the final command and compares it against the hash recorded in .ninja_log:

    cpp
    // src/graph.cc — RecomputeOutputDirty (abridged)
    if (!generator &&
        BuildLog::LogEntry::HashCommand(command) != entry->command_hash) {
      explanations_.Record(output, "command line changed for %s", …);
      return true;   // dirty: the recipe itself changed
    }

This is a deliberate divergence from the content-addressed engines: Ninjadoes not hash file contents by default, so a touched-but-byte-identical input will trigger a rebuild (the restat attribute mitigates the cascade for generators). The payoff is startup speed — comparing mtimes and one command hash is far cheaper than digesting every file, which is the whole point at 30,000 files.

Two on-disk logs make restart cheap

Ninja keeps two databases in builddir (default: the build root):

  • .ninja_log — for every built output, the hash of the command used and the recorded start/end mtimes. Used for the command-changed check above and to seed the critical-path scheduler with historical run times.

  • .ninja_deps — the compacted header-dependency database produced by deps = gcc/msvc. It is a binary log of path records and per-output dependency records, with the version stamped in the header (kCurrentVersion = 4, src/deps_log.cc). Each dep record sets a high bit in its size word to distinguish it from a path record:

    cpp
    // src/deps_log.cc — RecordDeps (abridged)
    unsigned size = 4 * (1 + 2 + node_count);
    size |= 0x80000000;   // Deps record: set the high bit
    // ... then write: id, mtime_lo, mtime_hi, and node_count input ids

    The log is append-only and self-compacting: ninja -t recompact (and an automatic threshold) rewrites it to drop dead records.

The scheduler: a critical-path-weighted ready queue

The executor side lives in Plan (src/build.h, src/build.cc). AddTarget walks the transitive closure of requested targets, marking each edge with a Want (kWantNothing / kWantToStart / kWantToFinish). Then PrepareQueue runs two passes:

  1. ComputeCriticalPath topologically sorts the reachable edges and propagates a critical_path_weight from leaves to roots — each edge's weight is its own cost plus the heaviest path beneath it:

    cpp
    // src/build.cc — ComputeCriticalPath (abridged)
    int64_t candidate_weight = edge_weight + EdgeWeightHeuristic(producer);
    if (candidate_weight > producer_weight)
      producer->set_critical_path_weight(candidate_weight);

    The weight heuristic is intentionally cheap (is_phony() ? 0 : 1, src/build.cc) but historical durations from .ninja_log refine it, so the longest build chains start first.

  2. ScheduleInitialEdges pushes every ready (kWantToStart) edge into an EdgePriorityQueue keyed on that weight.

The build loop then repeatedly FindWork()s the highest-priority ready edge, runs it via a CommandRunner (which spawns subprocesses — sh -c on POSIX, CreateProcess on Windows), and on completion re-evaluates dependents (EdgeFinishedNodeFinishedEdgeMaybeReady). "Builds are always run in parallel, based by default on the number of CPUs your system has" (manual), overridable with -j. There is no thread pool for the build logic itself — it is a single-threaded scheduler driving N concurrent subprocesses.

Pools and the console pool

A pool caps concurrency below the global -j for a subset of edges — "to restrict a particular expensive rule (like link steps for huge executables)":

ninja
pool link_pool
  depth = 4

rule link
  command = …
  pool = link_pool       # at most 4 links run at once

A pre-defined console pool (depth = 1, src/state.ccPool State::kConsolePool("console", 1)) gives its single task direct access to Ninja's stdin/stdout/stderr (for interactive or progress-printing tasks like test suites), buffering other output while it runs.

Dynamic dependencies (dyndep)

Most dependency discovery (headers) is needed only on the second build. Some languages (Fortran modules, C++20 modules) need it on the first — you cannot know the edge inputs until you read a freshly-built file. The dyndep binding (1.10+) names an input that Ninja reads during the build to add implicit inputs/outputs and patch the graph in flight, with the constraint that "a dyndep file may not change the build graph in a way that causes up-to-date build statements to become out-of-date" (manual § Dynamic Dependencies).


The five dimensions

1. Workspace declaration & topology

  • There is no workspace manifest and no member list. A Ninja "workspace" is a single build.ninja file (default name) in the build root; ninja "looks for a file named build.ninja in the current directory" (manual). There is no members = […] array, no glob, no root-vs-virtual distinction — contrast Cargo's [workspace], pnpm's globbed pnpm-workspace.yaml, or go.work. The dependency graph is the workspace: whatever the generator emitted into edges.

  • Composition is by textual inclusion, with two scoping rules. Large graphs are split across many files and stitched with subninja path and include path (manual § Evaluation and scoping):

    • subninja introduces a new scope — the child may read and shadow the parent's variables/rules but cannot mutate the parent. This is how a generator gives each sub-directory or sub-package its own cflags without leakage.

    • include splices the file into the current scope, like a C #include.

      The lookup order for a variable is fixed: built-ins ($in/$out) → build-edge bindings → rule bindings → file-level → the subninja-including file.

  • One build tree per configuration. The conventional pattern (inherited from the generators) is one output directory per build variant — out/Debug, out/Release — each a fully independent build.ninja with its own .ninja_log/.ninja_deps. Ninja itself is agnostic; ninja -C dir just cds there first.

  • Implication for dub. Ninja's topology answer is the opposite of the package-manager tools: it has no notion of "package" at all, only files and edges. It demonstrates that a monorepo graph can be expressed entirely as a flat, generated edge list — but it pushes 100% of the "which packages exist, where, and at what version" question up into the generator (which is exactly the job dub's resolver already does). See the GN+Ninja page for how a generator slices a tree into that flat graph.

2. Dependency handling & isolation

  • No package manager, no resolver, no lockfile, no fetching — by design. This is the sharpest contrast with every language-package-manager in the survey. Ninja has zero concept of versions, registries, hoisting, symlink trees, or virtual stores. There is nothing analogous to dub.selections.json, Cargo.lock, or pnpm's content-addressed store. Every file that participates must already exist on disk (or be produced by another edge); a missing explicit input that no edge produces aborts the build.
  • Cross-references are graph edges, and that is the whole isolation story. A library produced in one part of the tree is depended on from another purely by naming its output path as an input. There is no workspace: protocol (yarn-berry), no path= dependency — because there are no packages, only nodes. Topological build order falls out of the DAG for free.
  • Header dependencies are the one place Ninja discovers dependencies, via depfile/deps into .ninja_deps (above). When Ninja loads these implicit edges it "implicitly adds extra build edges such that it is not an error if the listed dependency is missing" — so deleting a header and rebuilding doesn't abort.
  • No sandboxing, no hermeticity. Unlike Bazel/Buck2, Ninja runs commands in the ambient filesystem with no per-action namespace; an undeclared read is invisible to it. The shipped guard-rail is the ninja -t missingdeps tool (1.11+), which finds "targets that depend on a generated file, but do not have a properly (possibly transitive) dependency on the generator … [which] may cause build flakiness on clean builds" (manual) — a diagnostic, not an enforcement.

3. Task orchestration & scheduling

  • Ninja does build a real DAG and execute it concurrently — this is its core competency. The generator emits edges; Ninja constructs the file-level DAG, computes critical-path weights, and runs every ready edge in parallel up to -j (default = CPU count). The Plan/EdgePriorityQueue/CommandRunner machinery (src/build.cc) is the engine.

  • Change detection is mtime + command-hash (§ How it works), not content hashing and not git-diff-based. An edge re-runs when an input is newer, the output is missing, or the recorded command changed. Header changes propagate correctly through .ninja_deps. This is fast but can over-build on touched-but-unchanged files; restat prunes the cascade for idempotent generators.

  • Affected-target / monorepo slicing is a query, not a --since flag.Ninja has no built-in "build what changed since git ref" affordance like Turborepo's --filter=…[ref] or Nx's affected. Instead it ships graph-query tools you run yourself:

    • ninja -t query <target> — inputs and outputs of one target;

    • ninja -t inputs <targets> (1.11+) — the full transitive input set;

    • ninja -t multi-inputs <targets> (1.13+)<target>⇥<input> pairs, "helpful if one would like to know which targets are affected by a certain input";

    • ninja -t targets / ninja -t graph (Graphviz) / ninja -t browse (web UI).

      The CI idiom is to feed a git diff into -t multi-inputs/-t inputs and build only the affected outputs — affected-detection assembled from primitives, not a first-class command.

  • Concurrency controls: global -j N (jobs), -l N (load-average cap), and per-subset pool depth. Since 1.13, Ninja is also a GNU Make jobserver client (src/jobserver.cc, blog): when invoked under a top-level make -jN (POSIX FIFO protocol, GNU Make ≥ 4.4) with no explicit -j, it draws job slots from the shared jobserver pool instead of its own, so nested builds share one global job budget rather than oversubscribing the machine.

4. Caching & remote execution

  • No build cache, no remote cache, no remote execution — none. This is Ninja's starkest gap versus Bazel/Buck2/Pants. The only form of "caching" is incrementality within one output tree: the .ninja_log + .ninja_deps databases let a re-run skip up-to-date edges. There is no content-addressed store, nothing shared across machines, and no REAPI client anywhere in the binary.

  • Caching/RBE must be bolted on at the command level. Because a rule's command is an opaque string, large shops wrap the compiler — prefixing cc/cxx with ccache/sccache for local content caching, or with a reclient/reproxy rewrapper that speaks REAPI to a remote cluster. Ninja is entirely unaware this is happening; it just runs the string. REAPI backends one might point such a wrapper at are surveyed under BuildBuddy, Buildbarn, and NativeLink.

    NOTE

    This is the inverse of the hermetic engines, where remote caching and REAPI execution are core features keyed on input hashes. With Ninja the engine is cache-agnostic and the organization assembles the RBE stack (ccache / reclient / a CAS cluster) around it. Google's strategic answer is to replace the executor: Siso, a Go drop-in for Ninja with native remote execution/caching, is mid-rollout across Chromium — see the GN+Ninja deep-dive. Ninja proper stays cache-free.

5. CLI / UX ergonomics

  • One binary, one default verb. Bare ninja builds the default targets of build.ninja in the cwd; ninja foo.o bar builds named targets (which are file paths, not package names). -C dir changes directory first, -j N sets parallelism, -n is dry-run, -v prints full commands. "Many of Ninja's flags intentionally match those of Make" (manual).
  • Targets are output paths; there is no package selector. Unlike the --filter/-p selectors of pnpm/Turborepo or Cargo's -p, Ninja selection is by output file or path — e.g. ninja chrome builds the edge producing chrome. The target^ syntax builds "the first output of some rule containing the source you put on the command line" (handy for "compile just this one .c").
  • -t subcommands are the entire introspection surface. query, inputs, multi-inputs, targets, graph, browse, commands, deps, missingdeps, compdb (emit a Clang JSON compilation database), compdb-targets, clean, cleandead, recompact, restat, rules. compdb in particular is why Ninja-backed projects get IDE/clangd integration for free.
  • Verbosity & explanation. -d explain prints why each dirty edge is being rebuilt (the explanations_ records seen in graph.cc), and -d stats dumps the METRIC_RECORD timers — first-class debuggability for "why did this rebuild?"

Strengths

  • Blazing incremental startup. mtime + one command hash + two compact binary logs means "what changed?" is answered in well under a second on 30k-file trees — the founding requirement, still its headline.
  • Correct header dependencies. deps = gcc/msvc into .ninja_deps solves the Makefile header-tracking problem that motivated the project, with no manual edge maintenance and minimal startup cost.
  • A real parallel DAG scheduler. Critical-path-weighted ready queue, pools for throttling expensive rules, -l load capping, and (1.13+) GNU Make jobserver participation for nested builds.
  • Tiny, dependency-free, ubiquitous. ~10k lines of C++ in one vendorable binary; the de-facto backend for CMake, Meson, GN, Kati, and more — the executor's generality is proven by how many generators target it.
  • Excellent introspection. -t graph/browse/compdb/inputs/multi-inputs plus -d explain make the graph queryable and rebuilds explainable.
  • Predictable, policy-free. No magic, no decisions, no hidden state — what the generator emits is exactly what runs.

Weaknesses

  • No dependency management whatsoever. No fetch, no versions, no lockfile, no registry — every input must already exist; a separate tool must supply them.
  • No caching or remote execution. Native Ninja caches only via the local output tree; cross-machine reuse and RBE require wrapping the compiler (ccache/reclient) or swapping the executor (Siso).
  • No hermeticity / no content hashing. mtime-based detection over-builds on touched files and, without sandboxing, silently misses undeclared reads; correctness leans on -t missingdeps and generator discipline.
  • Not meant to be authored by hand. The format is deliberately featureless — no conditionals, loops, functions, or globs — so you need a generator, making Ninja half of a two-tool workflow.
  • No package/workspace concept. Selection is by output path, not by package; there is no --filter/-p/--since, so monorepo slicing is hand-assembled from -t queries.
  • Crude scheduling cost model. The critical-path heuristic is phony ? 0 : 1 refined by historical log times — fine, but far from a true cost-aware scheduler.

Key design decisions and trade-offs

DecisionRationaleTrade-off
"Aims to be an assembler"; speed over convenienceSub-second incremental startup on 30k-file trees — the founding requirementThe format is unwritable by hand; you must pair it with a generator
All decisions pushed into a separate generatorKeeps the executor branch-free and policy-free, hence fast and predictableA two-tool workflow; no globs/conditionals/search-paths in Ninja itself
No package manager / resolver / lockfileNinja is a pure executor; deps are whatever exists on diskNeeds an entirely separate tool to fetch/version/place inputs; no registry story
mtime + command-hash change detection (not content hashing)Comparing mtimes and one hash is far cheaper than digesting every fileOver-builds on touched-but-unchanged files; restat only partly mitigates
deps = gcc/msvc → compact .ninja_deps databaseAvoids re-reading thousands of .d files on startup (esp. on slow Windows FS)A binary log format to version/recompact; header info is only correct from the second build on
Two append-only logs (.ninja_log / .ninja_deps)O(1)-ish restart; seeds the critical-path scheduler with real timingsLogs can grow and need -t recompact; corruption forces a clean rebuild
Critical-path-weighted ready queueStarts the longest dependency chains first to shorten wall-clockHeuristic weight (phony?0:1 + history) is crude vs. a real cost model
No built-in caching / remote executionKeeps the binary tiny and the engine cache-agnostic; org picks any backendRBE/caching must be wrapped on (ccache/reclient) or the executor replaced (Siso)
No sandbox; correctness via -t missingdeps + disciplineZero sandbox overhead; runs commands in the ambient filesystemNot hermetic — undeclared reads are invisible; missingdeps only diagnoses, doesn't prevent
Selection by output path, no package/--filter/--sinceMatches the "files and edges, not packages" model; mirrors Make's CLIMonorepo affected-target slicing must be hand-built from -t inputs/multi-inputs + a git diff
GNU Make jobserver client (1.13+)Nested builds share one global job budget instead of oversubscribing coresPOSIX needs the FIFO protocol (GNU Make ≥ 4.4); client-only, not a server

Sources