Waf (C/C++/native)
A self-contained, Python-based build framework in which the build description is a Python program (wscript), the multi-directory project tree is assembled imperatively by recurse()-ing into sub-directory scripts, and change detection is content-hashing of task signatures (input files + command
- environment variables) rather than timestamps.
| Field | Value |
|---|---|
| Language | Python (2.7–3.x, Jython, PyPy); the build configuration language is Python itself — wscript files are executed |
| License | BSD (3-clause; Thomas Nagy) |
| Repository | ita1024/waf (GitLab; canonical), waf-project/waf mirror |
| Documentation | The Waf Book · API docs · waf.io |
| Category | Native Build System |
| Workspace model | Single source tree rooted at one wscript; sub-directories joined imperatively by ctx.recurse('sub') into one DAG |
| First released | 2005 (forked conceptually from SCons/Cons lineage by Thomas Nagy; waf 1.0 mid-2000s) |
| Latest release | 2.1.9 (self-reported WAFVERSION = "2.1.9", HEXVERSION = 0x2010900) |
Latest release (as of June 5, 2026):
2.1.9, perwaflib/Context.py(WAFVERSION="2.1.9", revision387d01c…); themastercheckout used for the file paths quoted below is at commit4d54214(2026-05-19). Waf ships as a single ≈100 KiB executable script with thewaflib/library base64-embedded inside it — there is nothing to install butPython. The2.0/2.1lines arePython 3-friendly (2.0still runs onPython 2.7);2.1is the current development line. Within this survey Waf is the canonical imperative, single-file, hash-based native build system — contrast the declarative generate-then-execute model ofMeson/GNand the content-signature kinship withSCons.
Overview
What it solves
Waf targets the portable native build problem — compile and link C/C++ (plus D, Fortran, Vala, C#, Java, assembly, …) across GCC/Clang/MSVC and across Unix/macOS/Windows — but it makes a distinctive set of bets that separate it from both Make-style tools and the declarative-DSL generation tools. Quoting the project's own README.md (README.md):
"Waf is a Python-based framework for configuring, compiling and installing applications."
It then lists the features that matter for a multi-package tree:
- "Automatic build order: the build order is computed from input and output files, among others"
- "Automatic dependencies: tasks to execute are detected by hashing files and commands"
- "Performance: tasks are executed in parallel automatically, the startup time is meant to be fast (separation between configuration and build)"
- "Flexibility: new commands and tasks can be added very easily through subclassing … through dynamic method replacement" (
README.md)
Unlike Meson/GN (which generate a build.ninja and hand it to a separate executor) or CMake (which generates Makefiles/IDE projects), Waf is its own executor: a single Python process reads the wscripts, builds an in-memory task DAG, and schedules the compile/link commands itself with its own thread-pool runner. There is no generated intermediate build file. This makes Waf closest in spirit to SCons — both execute the configuration program directly and both detect change by hashing, not mtime — but Waf is engineered for a fast startup and a small, embeddable core.
Design philosophy
From the Waf Book's introduction (The Waf Book), the four constraints that shape everything:
- "Waf requires only Python to function and does not depend on any additional software or libraries."
- "Waf does not introduce a new language, as it is built using reusable Python modules."
- "Waf does not rely on a code generator such as Makefiles, resulting in efficient and extensible builds."
- "Waf defines targets as objects, distinguishing between the definition of targets and the execution of commands."
Three consequences follow that govern the monorepo behavior analyzed below:
- The build description is a program, not data. A
wscriptis aPythonmodule with predefined functions (options,configure,build,dist, …); any of them can run arbitraryPython. There is no membership array, no restricted DSL — the "topology" of a multi-package tree is whatever thebuild()function chooses torecurse()into. - Configure once, build many. Waf separates a heavyweight
configurestep (probe compilers, resolve flags, write a config cachec4che/) from a fastbuildstep. The configuration result is persisted so subsequent builds skip re-probing — the "separation between configuration and build" the README cites. - Hash-based, not timestamp-based. A task re-runs when its signature (a hash of its inputs, its command line /
hcode, scanned implicit dependencies, and the environment variables it reads) differs from the stored one — the foundation that lets Waf bolt on a content-addressed sharedwafcacheacross machines.
How it works
The single file, the wscript, and the command words
Waf is invoked as ./waf <command> [<command> …]. Each command word maps to a same-named function in the top wscript. The canonical lifecycle:
./waf configure # probe toolchain, resolve options → write build/c4che/
./waf build # assemble the task DAG and run it (alias: ./waf)
./waf install # stage build outputs to the prefix
./waf dist # roll a source tarball
./waf distclean # remove the build directory + lock filewaf_entry_point (waflib/Scripting.py) bootstraps a run: it creates an options context, parses the command line, then climbs the directory tree upward looking for the project lock file (.lock-waf_<platform>_build, default from Options.lockfile) or the first wscript — so ./waf works from any sub-directory of a configured project. A minimal top wscript (demos/c/wscript):
# wscript (project root)
VERSION = '0.0.1'
APPNAME = 'cc_test'
top = '.' # source root
out = 'build' # build (variant) directory
def options(opt):
opt.load('compiler_c gnu_dirs')
def configure(conf):
conf.load('compiler_c gnu_dirs')
def build(bld):
bld.recurse('program stlib stlib-deps shlib') # descend into sub-packagesThe options/configure/build functions each receive a context (OptionsContext, ConfigurationContext, BuildContext) — Waf "defines targets as objects, distinguishing … definition of targets and … execution of commands." Targets are declared by task generators (bld.program(...), bld.stlib(...), bld.shlib(...), or a raw bld(rule=..., ...)) which are posted into concrete Task objects only when the build runs.
1. Workspace declaration & topology
Waf has no workspace manifest and no membership glob. A "workspace" is simply a tree of wscript files, and topology is constructed imperatively by calls to recurse(). There is exactly one root wscript (found by the upward climb); every sub-package is pulled in only because some parent function explicitly recurses into it.
Context.recurse(dirs, name=None, mandatory=True, once=True) (waflib/Context.py) is the whole mechanism. For each directory it loads that directory's wscript and invokes the same-named function as the current command (so bld.recurse('src') calls src/wscript's build, while conf.recurse('src') calls its configure):
# waflib/Context.py — Context.recurse (abridged)
for d in Utils.to_list(dirs):
if not os.path.isabs(d):
d = os.path.join(self.path.abspath(), d)
WSCRIPT = os.path.join(d, WSCRIPT_FILE) # 'wscript'
WSCRIPT_FUN = WSCRIPT + '_' + (name or self.fun) # e.g. 'wscript_build'
node = self.root.find_node(WSCRIPT_FUN)
if node and (not once or node not in cache):
# a bare `wscript_build` file: exec its body directly
exec(compile(node.read(...), node.abspath(), 'exec'), self.exec_dict)
elif not node:
node = self.root.find_node(WSCRIPT) # a full 'wscript'
user_function = getattr(load_module(node.abspath()), name or self.fun, None)
user_function(self) # call build()/configure()/…Two file conventions exist: a full wscript (a Python module whose build/configure/… functions are looked up by name) or a lighter wscript_build (a bare script body exec'd directly in the build phase — the common case for leaf directories). A real multi-package tree (demos/c/stlib-deps/) is just nested recurses:
# demos/c/stlib-deps/wscript_build (a sub-package "workspace" root)
bld.recurse('libA')
bld.recurse('libB')
bld.recurse('libC')
bld.program(source='main.c', target='test_static_link_chain', use='B C')# demos/c/stlib-deps/libA/wscript_build
bld.stlib(target='A', source='external_vars.c',
includes='.', export_includes='.') # exports its public include dirBecause recurse runs arbitrary Python, "membership" can be computed — e.g. bld.recurse([n for n in os.listdir('libs') if …]) — but there is no declarative list a tool can read without executing the build. This is the polar opposite of Cargo's members = ["libs/*"] or pnpm's packages: glob: Waf's topology is imperative and demand-driven, discovered only by running the script. The once=True default makes each wscript execute at most once per context, so diamond recursion is idempotent rather than re-entrant.
NOTE
The default out = 'build' puts all outputs of the whole recursed tree into a single root build directory — Waf is an out-of-source build with one shared variant dir for the entire monorepo, not one per sub-package. This is the structural reason cross-package artifact reuse works at all (compare Meson, which also unifies into one builddir).
2. Dependency handling & isolation
Waf is not a package manager: there is no registry, no lockfile of external versions, no symlink store, and no hoisting. Third-party libraries are either detected on the system at configure time (conf.check, conf.check_cfg → pkg-config) or vendored as source into the tree and built as ordinary sub-packages. The interesting dimension is therefore local cross-references between sibling packages, and Waf's answer is the use keyword.
A task generator names the libraries it consumes via use='A B'; the process_use method (waflib/Tools/ccroot.py) resolves those names to sibling task generators, topologically sorts the transitive use graph (raising on cycles), and propagates linker inputs and exported metadata downstream:
# waflib/Tools/ccroot.py — process_use (abridged)
names = self.to_list(getattr(self, 'use', []))
for x in names:
self.use_rec(x) # recurse the transitive use graph
# topological sort of self.tmp_use_seen …
if use_prec:
raise Errors.WafError('Cycle detected in the use processing %r' % use_prec)
# downstream inherits each used lib's exported include dirs & defines:
if getattr(y, 'export_includes', None):
self.includes = self.includes + y.to_incnodes(y.export_includes)
if getattr(y, 'export_defines', None):
self.env.append_value('DEFINES', self.to_list(y.export_defines))So use='A' does three things at once: (a) adds A's output (the .a/.so) as a link input, creating a run_after ordering edge in the task DAG; (b) folds A's export_includes into the consumer's include path; (c) folds A's export_defines into the consumer's macros. This is Waf's equivalent of a local cross-reference (Yarn's workspace: protocol, Cargo path deps) — but resolved by task-generator name within one build, not by a manifest dependency declaration. use also chains transitively (B uses A, the program uses B C, and A is pulled in automatically).
Waf even lets a package opt out of one isolation default: libC above carries features='skip_stlib_link_deps', documented in the demo as preventing static libraries from depending on each other so "the only way libC is re-archived is if … diff.c or any of its dependencies change." Isolation between sibling packages is therefore tunable per task generator, not a fixed policy.
IMPORTANT
Because every sub-package builds into the same build/ directory and shares the same BuildContext, Waf gets cross-package reuse for free within one tree: libA is compiled once and linked into every dependent. But there is no cross-project store — two separate repos that both vendor zlib each build their own copy (the same limitation as Meson; contrast the content-addressed sharing of Bazel/pnpm, or Waf's own opt-in wafcache, which can bridge that gap).
3. Task orchestration & scheduling
This is Waf's strongest dimension and where it differs most from the generate-then-execute tools. Waf is the scheduler: it builds a task DAG in memory and runs it with its own producer/consumer thread pool — there is no ninja underneath.
The DAG. Each task generator is posted into one or more Task objects with explicit inputs, outputs, and run_after predecessor edges (the edges that use and bld.add_manual_dependency create). Tasks are partitioned into ordered build groups (bld.add_group() / bld.set_group(), waflib/Build.py); all tasks in group N complete before group N+1 begins, which is how cross-cutting ordering (codegen before compile) is expressed when a pure data dependency is not enough.
Change detection by signature. A task's runnable_status() (waflib/Task.py) compares a freshly computed signature() against the value stored from the previous build:
# waflib/Task.py — Task.signature (abridged)
self.m = Utils.md5(self.hcode, usedforsecurity=False)
self.sig_explicit_deps() # hash the input files
self.sig_vars() # hash the env vars / task.vars the command reads
if self.scan:
self.sig_implicit_deps() # hash scanner results (e.g. #include graph)
ret = self.cache_sig = self.m.digest()# waflib/Task.py — Task.runnable_status (abridged)
for t in self.run_after:
if not t.hasrun: return ASK_LATER # predecessor not done yet
elif t.hasrun < SKIPPED: return CANCEL_ME # predecessor failed
new_sig = self.signature()
prev_sig = bld.task_sigs[self.uid()] # from the persisted DB
if new_sig != prev_sig: return RUN_ME # inputs/command/env changed
# … also verify each output still exists and was produced by *this* task …
return (self.always_run and RUN_ME) or SKIP_METhe signature mixes hcode (a hash of the command/rule itself, so changing a compiler flag invalidates), explicit input hashes, implicit-dependency hashes from language scanners (the C preprocessor #include scan lives in c_preproc.py), and sig_vars (the environment variables the task reads). A task SKIP_MEs only if all of these match and every output file still exists and is still attributed to it.
Parallel execution. Runner.Parallel (waflib/Runner.py) is a producer that feeds ready tasks to a Spawner thread, which spins up one short-lived Consumer thread per task, bounded by a semaphore set to --jobs:
# waflib/Runner.py — Spawner (abridged)
class Spawner(Utils.threading.Thread):
def __init__(self, master):
self.sem = Utils.threading.Semaphore(master.numjobs) # cap concurrency
...
def loop(self):
while 1:
task = master.ready.get()
self.sem.acquire()
Consumer(self, task) # one consumer thread runs one taskrefill_task_list walks the build-group iterator, calls prio_and_split to separate tasks whose predecessors are done (outstanding, runnable now) from those still waiting (incomplete), and as each task finishes, mark_finished un-freezes any successor all of whose run_after predecessors have now run. A deadlock detector fires if postponed tasks stop making progress — the common cause being "conflicting build order declaration, for example X run_after Y and Y run_after X" (waflib/Runner.py). So within one process Waf gives automatic, dependency-correct parallelism and incremental rebuilds with no external executor.
NOTE
There is no affected-by-Git-ref slicing (--since <ref>) and no content hashing across repository revisions the way Nx/Turborepo compute affected projects. Incrementality is purely "did this task's signature change since the last local build", read from the persisted DB below. The --targets/--files flags (see §5) provide the only built-in slicing.
4. Caching & remote execution
Waf has three layers of caching, increasing in scope:
(a) The persisted build database — local incrementality. After each build, BuildContext.store (waflib/Build.py) pickles a fixed set of attributes to build/.wafpickle-<platform>-<pyver>-<abi>:
# waflib/Build.py
SAVED_ATTRS = 'root node_sigs task_sigs imp_sigs raw_deps node_deps'.split()task_sigs (task UID → last signature) and node_sigs (output file → producing task) are exactly what runnable_status consults next time, so this pickle is the incremental cache. The configure step separately writes the resolved environment to the config cache directory build/c4che/ (CACHE_DIR = 'c4che'), which is why a reconfigure is unnecessary on a plain rebuild. The project lock file .lock-waf_<platform>_build records the configured run_dir/top_dir/ out_dir so the entry-point climb can re-anchor.
(b) wafcache — a content-addressed shared object cache. The optional wafcache tool turns task signatures into cache keys and stores task outputs keyed by (task.uid, signature), so an identical compile on another checkout — or another machine — is fetched instead of re-run. It is configured entirely through the environment (wafcache docs):
# local shared folder (default ~/.cache/wafcache_user), or a cloud bucket:
WAFCACHE=gs://my-bucket/ ./waf build # Google Cloud Storage
WAFCACHE=s3://my-bucket/ ./waf build # S3
WAFCACHE=minio://my-bucket/ ./waf build # MinIO
WAFCACHE_NO_PUSH=1 ./waf build # read-only (CI consumers)
WAFCACHE_EVICT_MAX_BYTES=… WAFCACHE_EVICT_INTERVAL_MINUTES=… # LRU trimBecause the key is the content signature, wafcache is Waf's content-addressed analogue to a Bazel action cache — bridging the cross-project reuse gap §2 noted — but it caches compile/link outputs, not hermetic actions, and trusts the signature rather than sandboxing inputs.
(c) netcache_client — a network cache protocol. A second extra (waflib/extras/netcache_client.py) implements a push/pull client against a small cache server (NETCACHE=host:port, default push 11001 / pull 12001): bld.load('netcache_client') makes the build fetch task outputs over a socket before executing them locally.
WARNING
None of these is a REAPI remote-execution backend. Waf can cache and fetch task outputs remotely (wafcache to a cloud bucket, netcache to a server), but it always executes tasks in its own local process pool — there is no farming of actions out to a cluster the way Bazel/Buck2/ Pants drive BuildBuddy/Buildbarn/NativeLink. Remote = remote cache, not remote execution.
5. CLI / UX ergonomics
Waf's command boundary is command words that map to wscript functions, with a small set of global flags rather than a per-package --filter grammar (waflib/Options.py):
| Flag / form | Role |
|---|---|
./waf configure build | Run several commands in sequence; each calls the same-named wscript function |
-j N, --jobs N | Parallel job count for the runner's semaphore (defaults to CPU count / $JOBS) |
--targets=t1,t2 | Build only the named task generators (and their dependencies) — package slicing |
--files=*/main.c,*/x.o | "Step" mode: process only files matching the regexp (per-file slicing) |
-o, --out DIR | Override the build (variant) directory |
-t, --top DIR | Override the source root (skip the upward wscript climb) |
-p, --progress | Progress bar (-pp = IDE-style output) |
--zones=…, -v | Debug zones / verbosity (task_gen, deps, tasks, …) |
--prefix, --destdir | Install layout |
The closest thing to a "run X across every member" loop is built into the model itself: because ./waf build already recurses the whole tree into one DAG, a single command is the broadcast — there is no yarn workspaces foreach (yarn-berry) equivalent because the build is unified. Slicing down to a sub-package is done with --targets (by task-generator name) or --files (by path regexp), not a -p <package> / --filter <pattern> / --since <ref> vocabulary like pnpm/Nx/Turborepo.
New commands are trivially added — subclass BuildContext and the class name becomes a ./waf <name> command — so projects routinely grow custom verbs (./waf docs, ./waf benchmark) that themselves recurse() the tree. The ergonomics are thus "one process, many command words, arbitrary Python per command," which is maximally flexible and minimally standardized.
Strengths
- Zero install, single file. Waf is one ≈100 KiB
Pythonscript with the library embedded; "requires only Python to function and does not depend on any additional software." A project vendorswafand is self-contained. - Its own fast parallel executor. No generated
Makefile/build.ninjaand no second tool: Waf builds the DAG and runs it with a semaphore-bounded thread pool, with automatic build order and a deadlock detector. - Hash-based, command-aware incrementality. Signatures fold input hashes, the command/
hcode, scanner-discovered implicit dependencies, and the environment variables a task reads — so flag changes and header changes both invalidate correctly, withoutmtimegames. - Imperative
recurse()topology. A multi-package tree is just nestedwscripts; "membership" can be computed inPython, and everything compiles into one sharedbuild/for free cross-package reuse within the tree. use-based local cross-references with transitiveexport_includes/export_definespropagation and a topological sort with cycle detection.- Content-addressed shared cache (
wafcache) to local folders or GCS/S3/MinIO buckets, plus anetcachenetwork-cache client — cross-machine output reuse. - Extensible to the core. New commands, task classes, and tools are subclasses; "bottlenecks for specific builds can be eliminated through dynamic method replacement."
Weaknesses
- No declarative manifest. Topology, dependencies, and options are all expressed as executing
Python; a tool cannot read the member set or the dependency graph without running the build, defeating static analysis. - No external package manager / version resolution. No registry, no lockfile of upstream versions, no
workspace:-style protocol — external deps are system-probed or vendored as source by hand. - Remote cache, not remote execution.
wafcache/netcachefetch outputs; there is no REAPI action execution on a cluster. - No affected-by-ref slicing. Incrementality is per-local-build signature diffing; no
--since <ref>/Git-aware affected-project computation. Python-program builds are powerful but un-sandboxed. Arbitrary code inwscripts makes builds hard to reason about, cache hermetically, or audit — the same double edge asSCons.- Smaller, niche community. Long associated with specific large C/C++ codebases (e.g. Samba) and a single primary author; documentation is good but the ecosystem is far smaller than
CMake/Meson. - Single-machine scaling ceiling. Parallelism is one process's thread pool; scaling beyond one host means the optional caches, not distributed execution.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Build description is a Python program (wscript) | Full language power, no new DSL, "dynamic method replacement" for tricky builds | Topology/deps are opaque to tooling; nothing is statically readable without executing the build |
Be its own executor (no generated Makefile/ninja) | One tool, one process; full control over scheduling and incrementality | No reuse of ninja's mature executor; parallelism is bounded by one host's thread pool |
Imperative recurse() topology (no membership array/glob) | A package tree is just nested scripts; membership can be computed | No declarative "all members" view; CI/IDE tooling must run Waf to learn the graph |
Hash task signatures (inputs + hcode + scanner + env vars) | Correct, command-aware incrementality; foundation for a shared content cache | Hashing cost per task; trusts signatures rather than sandboxing inputs |
use = name-based local cross-reference, topologically sorted | Sibling libraries wire up by name with transitive include/define export | References resolve only within one build; not a cross-project dependency declaration |
One shared out = 'build' for the whole recursed tree | Free cross-package artifact reuse; out-of-source by default | No per-package isolation of outputs; no cross-project store without wafcache |
Caching layered as optional extras (wafcache, netcache) | Core stays a tiny single file; cloud/shared caching is opt-in | Remote = cache only, never remote execution; configured via env vars, not first-class flags |
| Ship as one embedded single-file script | Zero-dependency, vendorable, reproducible toolchain | Upgrades mean re-vendoring the file; the embedded waflib can drift from the host Python |
Sources
ita1024/waf— canonical GitLab repository (source for all quoted file paths;master@4d54214,WAFVERSION 2.1.9)waf-project/waf— GitHub mirror- The Waf Book — design goals, model,
wscriptfunctions - Waf API documentation ·
waf.iohome README.md— "ABOUT WAF" feature list (verbatim)waflib/Context.py—recurse(),wscript/wscript_build, run-dir constantswaflib/Scripting.py—waf_entry_point, command dispatch, lock-file climbwaflib/Build.py—BuildContext, build groups,SAVED_ATTRSpersisted DB,c4chewaflib/Task.py—signature(),runnable_status(),RUN_ME/SKIP_MEstateswaflib/Runner.py—Parallelproducer,Spawner/Consumerthread pool, deadlock detectorwaflib/Tools/ccroot.py—process_use,export_includes/export_defines, topological sortwaflib/Options.py— CLI flags (-j,--targets,--files,-o,-t,-p)waflib/extras/netcache_client.py— network cache push/pull client- wafcache — content-addressed shared cache (
WAFCACHE, GCS/S3/MinIO) - Sibling tools:
SCons·Meson·CMake·GN+Ninja·Cargo·pnpm·Yarn Berry·Nx·Turborepo·Bazel·Buck2·Pants· the D landscape