Ninja Build System
Ninja build system — concepts, hand-written build.ninja, CMake + Ninja backend, incremental builds, compile_commands.json, and speed tips.
TL;DR
Ninja is a small, fast build system designed to be generated by higher-level tools like CMake. It beats Make on large C++ projects because it uses a simpler dependency model and runs with maximum parallelism by default.
# Use Ninja as CMake's build backend
cmake -G Ninja -B build
cmake --build build # parallel by defaultCore Concepts
Ninja has four primitives:
| Primitive | Purpose |
|---|---|
rule | Named build recipe (how to compile/link) |
build | Edge: outputs = rule(inputs) |
variable | String substitution |
pool | Limit concurrency for expensive steps |
Everything else (.d dependency files, implicit outputs, phony targets) builds on these.
Hand-Written build.ninja
# Variables
cxx = g++
cflags = -std=c++23 -O2 -Wall
# Rules
rule compile
command = $cxx $cflags -MMD -MF $out.d -c $in -o $out
depfile = $out.d
deps = gcc
description = Compile $in
rule link
command = $cxx $in -o $out
description = Link $out
# Build edges
build obj/main.o: compile src/main.cpp
build obj/engine.o: compile src/engine.cpp
build obj/renderer.o: compile src/renderer.cpp
# Link
build app: link obj/main.o obj/engine.o obj/renderer.o
# Default target
default appKey variables in rules
| Variable | Meaning |
|---|---|
$in | Space-separated list of input files |
$out | Space-separated list of output files |
$depfile | Path to GCC-format dependency file |
Automatic Dependency Tracking
The -MMD -MF $out.d flags make the compiler write a .d file listing all #included headers. Ninja reads these with deps = gcc and rebuilds correctly when any header changes — no manual dependency listing needed.
rule cxx_compile
command = g++ -std=c++23 -MMD -MF $out.d -c $in -o $out
depfile = $out.d
deps = gcc # tells ninja to read and cache the depfilePhony Targets and Pools
# Phony target (like .PHONY in Make)
build clean: phony
command = rm -rf obj/ app
build all: phony app tests
# Pool: limit link parallelism (linking is memory-heavy)
pool link_pool
depth = 2
rule link
command = $cxx $in -o $out
pool = link_pool # at most 2 link jobs at onceUsing Ninja with CMake
# Configure with Ninja generator
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
# Build (Ninja auto-detects CPU count for -j)
cmake --build build
# Build with explicit parallelism
cmake --build build -- -j16
# Clean
cmake --build build --target clean
# List all targets
cmake --build build --target helpCMakeLists.txt — nothing special needed
CMake generates build.ninja automatically. All standard CMake features (targets, dependencies, install) work identically with the Ninja generator.
compile_commands.json
Ninja + CMake generates compile_commands.json for clangd (LSP), clang-tidy, and other tools:
cmake -G Ninja -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# creates build/compile_commands.json
# Symlink to project root for clangd to find it
ln -sf build/compile_commands.json compile_commands.jsonUseful ninja Commands
ninja # build default target
ninja -j8 # limit to 8 parallel jobs
ninja app tests # build specific targets
ninja -n # dry run — show commands without running
ninja -v # verbose: print full commands
ninja -C build # run from a different directory
# Diagnostics
ninja -t list # list all targets
ninja -t deps app # show dependency chain for 'app'
ninja -t compdb # print compilation database (JSON)
ninja -t graph app | dot -Tpng > deps.png # visualize dependency graph
ninja -t clean # remove built outputs
ninja -t recompact # compact the .ninja_deps databaseIncremental Builds
Ninja tracks two kinds of state:
- Timestamps — if an output is older than its inputs, rebuild
- Dependency database (
.ninja_deps) — discovered header dependencies from depfiles
On large projects this makes a "no-op" build (nothing changed) extremely fast — Ninja just stat()s files and exits.
# Force rebuild of a specific target
ninja -t clean app && ninja app
# Show what would be rebuilt
ninja -n
# Check why something is being rebuilt
ninja -d explain app 2>&1 | head -20Speed Tips
# Use Ninja instead of Make (2-5x faster on large projects)
cmake -G Ninja ...
# Enable link-time parallelism with lld
add_link_options(-fuse-ld=lld)
# Split compilation with unity/jumbo builds (fewer TUs)
set_target_properties(mylib PROPERTIES UNITY_BUILD ON)
# Precompiled headers
target_precompile_headers(myapp PRIVATE src/pch.h)
# Use ccache with Ninja
cmake -G Ninja -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ...
# Distributed builds with distcc or icecc
set(CMAKE_CXX_COMPILER_LAUNCHER "distcc")Comparison: Ninja vs Make vs Meson
| Ninja | Make | Meson | |
|---|---|---|---|
| Role | Build runner (generated) | Build runner (hand-written or generated) | Meta-build (generates Ninja) |
| Parallelism | Default max | Manual -j$(nproc) | Default max (via Ninja) |
| Dependency tracking | Depfiles, deps = gcc | .d files (manual setup) | Automatic |
| Incremental accuracy | High | Medium (timestamp only) | High |
| Human-writable | Barely | Yes | Yes |
| Speed (no-op build) | Very fast | Slow (recursive make) | Very fast (via Ninja) |
compile_commands.json | Yes (via CMake flag) | With Bear/CMake | Built-in |
Typical workflow: write CMakeLists.txt or meson.build → generate Ninja files → Ninja executes the build.
Ninja Multi-Config Generator
One configuration step, every build type available simultaneously. The standard single-config Ninja generator forces you to re-run cmake whenever you switch between Debug and Release — cmake -G "Ninja Multi-Config" eliminates that friction by writing a single build.ninja that contains all requested configurations.
# Configure once
cmake -G "Ninja Multi-Config" -B build
# Build a specific configuration without re-running cmake
cmake --build build --config Release
cmake --build build --config Debug
cmake --build build --config RelWithDebInfo
# Run the Release binary immediately after
./build/Release/myappOutputs land in per-configuration subdirectories (build/Release/, build/Debug/), so Debug and Release artifacts never collide. This mirrors how Visual Studio and Xcode manage configurations and is ideal for CI pipelines that need both a fast debug binary and an optimised release build from a single checkout.
# CMakeLists.txt — set the config used when --config is omitted
set(CMAKE_DEFAULT_BUILD_TYPE Release)
# Limit which configs are generated (reduces disk use)
set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE)CMAKE_DEFAULT_BUILD_TYPE tells the multi-config generator which configuration cmake --build build (without --config) should produce, giving you a sensible default for local development while keeping all configs available on demand.
BYPRODUCTS and Custom Commands
Ninja's dependency graph must be complete — partial graphs cause silent stale-build bugs. When a add_custom_command invokes a code generator (e.g., protobuf, flatbuffers, Qt MOC), it produces files that CMake knows about but that Ninja's rule graph does not. Without BYPRODUCTS, Ninja treats those files as already up-to-date and skips downstream compilation steps.
# WRONG — Ninja doesn't know proto_gen produces these files
add_custom_command(
OUTPUT generated/foo.pb.cc generated/foo.pb.h
COMMAND protoc --cpp_out=generated foo.proto
DEPENDS foo.proto
)
# CORRECT — list every implicit output in BYPRODUCTS
add_custom_command(
OUTPUT generated/foo.pb.cc generated/foo.pb.h
COMMAND protoc --cpp_out=generated foo.proto
DEPENDS foo.proto
BYPRODUCTS generated/foo.pb.cc generated/foo.pb.h
)The BYPRODUCTS keyword maps directly to Ninja's | implicit_output syntax. With it, Ninja marks those files as owned by that build edge, invalidates them when foo.proto changes, and rebuilds every target that depends on them. Without it, a stale foo.pb.cc from a previous run silently survives even after you update the .proto source.
# What CMake emits in build.ninja with BYPRODUCTS (simplified)
build generated/foo.pb.cc generated/foo.pb.h | generated/foo.pb.cc generated/foo.pb.h: CUSTOM_COMMAND foo.proto
COMMAND = protoc --cpp_out=generated foo.protoApply the same pattern to any generator: flatc, qdoc, bison, flex, or in-house template expanders — any tool that writes files not listed as the primary OUTPUT.
Ninja Build Log Analysis
.ninja_log is a structured record of every build action Ninja has ever run. The file lives in the build directory and has the format:
start end restat output commandwhere start and end are millisecond timestamps relative to the start of that invocation. Sorting by end - start reveals your heaviest translation units.
# Show the 20 slowest build outputs in the last full build
sort -n -k 2 build/.ninja_log | tail -20
# Pretty-print the log with ninja's own tool
ninja -C build -t log
# Print rule execution statistics for the current run
ninja -C build -d statsFor visual analysis, the ninjatracing tool converts .ninja_log into Chrome's tracing format, which you can open in chrome://tracing or Perfetto to see a flame-chart of your build.
# Install ninjatracing (Python, one script)
pip install ninjatracing
# Convert and open
ninjatracing build/.ninja_log > build/trace.json
# Open build/trace.json in chrome://tracingThe critical-path column in the flame chart pinpoints which source file, not which rule, is your real bottleneck — a distinction that's invisible in raw wall-clock timing. Once you identify slow TUs, techniques like precompiled headers, unity builds, or moving template-heavy code into explicit instantiation files (extern template) are the right levers to pull.
Ninja with ccache and Distributed Builds
Compiler caches and distributed compilation are Ninja's fastest force-multipliers. ccache stores the output of each compilation keyed on the preprocessed source plus flags; a cache hit replaces a full compiler invocation with a file copy. sccache extends the same idea across machines using S3, GCS, or Redis backends — essential for CI fleets.
# ccache: set at configure time so it applies to every target
cmake -G Ninja -B build \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_C_COMPILER_LAUNCHER=ccache
# Check cache effectiveness after a build
ccache --show-stats
# sccache is a drop-in replacement with remote backends
cmake -G Ninja -B build \
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache \
-DCMAKE_C_COMPILER_LAUNCHER=sccache
# Point sccache at an S3 bucket
export SCCACHE_BUCKET=my-build-cache
export SCCACHE_REGION=us-east-1
sccache --start-serverCMAKE_CXX_COMPILER_LAUNCHER prepends the launcher binary to every compile command Ninja generates, so you don't need to modify your CMakeLists.txt at all — it works transparently with any target.
For distributed compilation, distcc dispatches individual TUs to a pool of remote build daemons over the network. The key tuning parameter is the job count: running more parallel jobs than the pool supports wastes time in queuing.
# Distributed build with distcc
export CXX="distcc g++"
cmake -G Ninja -B build
# Let distcc report the right -j for the current pool
ninja -C build -j$(distcc -j)
# Combine ccache + distcc (ccache checks first, distcc only for misses)
export CXX="ccache distcc g++"ccache and distcc compose cleanly: ccache checks its local cache first and only invokes distcc (and therefore the network) on a cache miss. This pattern is the standard setup for large teams where the same commits are built repeatedly across many developer machines.
Writing Efficient build.ninja Files
The best hand-written build.ninja is the one you almost never have to write. When you do write one — for a tightly controlled build, a firmware project, or a custom generator — a few rule-level properties prevent subtle rebuild inefficiencies.
RESTAT = 1 tells Ninja to re-stat the output after a rule runs. If the output's mtime is unchanged (the generator wrote identical content), Ninja cancels the rebuild cascade for every target downstream of that output. This is invaluable for code generators that conditionally rewrite their output:
# Without RESTAT: every downstream target rebuilds even if config.h didn't change
rule gen_config
command = python3 gen_config.py -o $out
description = Generate $out
# With RESTAT: Ninja re-stats config.h after gen_config; if identical, stops cascade
rule gen_config
command = python3 gen_config.py -o $out
description = Generate $out
restat = 1The console pool connects a rule's stdout/stderr directly to the terminal, bypassing Ninja's output buffering. Use it for rules that print interactive progress (test runners, custom install scripts):
pool console
depth = 1
rule run_tests
command = ./run_tests.sh
pool = console
description = Run test suiteThe depth knob on any pool caps how many simultaneous jobs that pool can run. Linkers are the canonical use case — each ld invocation can consume gigabytes of RSS, so bounding depth prevents the build from swapping on machines with limited RAM:
pool heavy_link
depth = 2
rule link_shared
command = $cxx -shared $in -o $out
pool = heavy_link
description = Link $outKeep variable expansion simple: avoid shell sub-expressions inside rule commands. Every $(...) in a rule command forces Ninja to spawn a shell instead of exec-ing the compiler directly, adding per-file overhead that accumulates across thousands of TUs. Move complex logic into wrapper scripts and call those scripts from the rule.
See Also
Related tools in the C++ build ecosystem:
- CMake — the de-facto standard meta-build system that generates Ninja files for most C++ projects
- Meson — a faster-to-configure meta-build that targets Ninja exclusively
- build2 — an integrated build toolchain with its own dependency manager; can emit Ninja-compatible output
- ccache — compiler cache that plugs into Ninja via
CMAKE_CXX_COMPILER_LAUNCHER