Skip to content
C++
Build System
Updated 2025-01-01T00:00:00.000Z

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.

bash
# Use Ninja as CMake's build backend
cmake -G Ninja -B build
cmake --build build          # parallel by default

Core Concepts

Ninja has four primitives:

PrimitivePurpose
ruleNamed build recipe (how to compile/link)
buildEdge: outputs = rule(inputs)
variableString substitution
poolLimit 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 app

Key variables in rules

VariableMeaning
$inSpace-separated list of input files
$outSpace-separated list of output files
$depfilePath 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 depfile

Phony 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 once

Using Ninja with CMake

bash
# 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 help

CMakeLists.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:

bash
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.json

Useful ninja Commands

bash
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 database

Incremental Builds

Ninja tracks two kinds of state:

  1. Timestamps — if an output is older than its inputs, rebuild
  2. 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.

bash
# 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 -20

Speed Tips

bash
# 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

NinjaMakeMeson
RoleBuild runner (generated)Build runner (hand-written or generated)Meta-build (generates Ninja)
ParallelismDefault maxManual -j$(nproc)Default max (via Ninja)
Dependency trackingDepfiles, deps = gcc.d files (manual setup)Automatic
Incremental accuracyHighMedium (timestamp only)High
Human-writableBarelyYesYes
Speed (no-op build)Very fastSlow (recursive make)Very fast (via Ninja)
compile_commands.jsonYes (via CMake flag)With Bear/CMakeBuilt-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.

bash
# 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/myapp

Outputs 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.

cmake
# 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.

cmake
# 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.proto

Apply 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:

cpp
start   end   restat   output   command

where start and end are millisecond timestamps relative to the start of that invocation. Sorting by end - start reveals your heaviest translation units.

bash
# 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 stats

For 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.

bash
# Install ninjatracing (Python, one script)
pip install ninjatracing

# Convert and open
ninjatracing build/.ninja_log > build/trace.json
# Open build/trace.json in chrome://tracing

The 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.

bash
# 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-server

CMAKE_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.

bash
# 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 = 1

The 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 suite

The 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 $out

Keep 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
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z