SCons (C/C++/native)
A pure-Python software construction tool: the build configuration is a Python program, the dependency graph is assembled in memory by executing that program, and change detection is content-hash (MD5/SHA-256) signatures rather than mtime — the "reliable builds, real programming language" answer to Make.
| Field | Value |
|---|---|
| Language | Python (engine and configuration language are both Python; SConstruct/SConscript files are executed as Python scripts) |
| License | MIT ("Copyright The SCons Foundation") |
| Repository | SCons/scons (GitHub; historically SourceForge) |
| Documentation | User Guide · scons man page · API reference |
| Category | Native Build System |
| Workspace model | Single source tree rooted at an SConstruct file; a hierarchy of SConscript files, one per subdirectory, executed into one DAG |
| First released | 2001 (descended from Cons, a Perl tool, via the 2000 ScCons Software Carpentry contest entry) |
| Latest release | 4.10.1 (November 16, 2025) |
Latest release (as of June 5, 2026):
4.10.1, released 2025-11-16, headlined by Visual Studio /MSVS2026 support;masteris at4.10.2(dev). The4.xline isPython 3-only and now ships theNewParallelscheduler as the default for all builds (including-j1), with the legacy scheduler demoted to opt-in--experimental=legacy_sched(CHANGES.txt). SCons is one of the oldest tools in this survey — predatingBazel,CMake's ubiquity, and every package manager here — and is unusual for being a native build system written entirely in a general-purpose scripting language.
Overview
What it solves
SCons solves the correct, reliable rebuild problem for native (C/C++/Fortran/D …) projects without inventing a domain-specific language. Where Make requires a bespoke Makefile syntax plus a separate make depend step (and trusts file timestamps, which lie under clock skew, touch, and restored backups), SCons makes two opposite bets:
The build description is ordinary
Python.SConstruct(the root) andSConscript(per-directory) files are not parsed as a config format — they are executed asPython. Loops, functions, classes,importof helper modules, and arbitrary computation are all available, because they arePython. From the project's own pitch (README.rst):"Configuration files are Python scripts — use the power of a real programming language to solve build problems; no complex domain-specific language to learn."
Change detection is content-based, not timestamp-based. SCons hashes file contents (and the command line, and the tools) into a build signature, and rebuilds only when a signature changes. Again from
README.rst:"Reliable, automatic dependency analysis built-in for C, C++ and FORTRAN. No more 'make depend' or 'make clean' to get all of the dependencies. … Reliable detection of build changes using cryptographic hashes; optionally can configure other algorithms including traditional timestamps."
Within this survey SCons is the "general-purpose-language build system" data point. Contrast the generator/executor split of GN + Ninja (a deliberately minimally expressive DSL feeding a dumb assembler) and the hermetic content-addressed engines Bazel / Buck2 (Starlark + sandboxed remote execution). SCons sits at the opposite pole from Ninja: it is the high-level policy tool and its own executor, all in one Python process — expressive, introspectable, and (its long-standing weakness) comparatively slow at scale. Make, Meson, CMake, Waf, and Ninja are the sibling native systems; Waf in fact began as a fork of SCons.
Design philosophy
SCons descends from Cons (Bob Sidebotham, 1996, in Perl) and was rewritten in Python after winning the 2000 Software Carpentry build-tool design competition. The philosophy that survived both rewrites: a build tool should be correct first, and correctness comes from modelling the build as a graph of Nodes with real content signatures, driven by a real language. Three consequences shape the entire API:
Everything is a
Nodein one in-memory DAG. Source files, derived files, directories,Values (in-memory strings), andAliases are allNodeobjects. Executing everySConstruct/SConscriptbuilds the whole graph before anything is built — SCons is a two-phase system (read all scripts → walk the DAG), not a recursive-descentMakewhere each directory is a separate process with a partial view. This is the cure for "recursive make considered harmful": there is one global graph, so cross-directory dependencies are always correct.Construction Environments, not global flags. A build is parameterized through
Environmentobjects — dictionaries of construction variables (CC,CCFLAGS,CPPPATH,LIBS, …) plus theBuildermethods (Program,Library,Object,SharedLibrary, …) that consume them. MultipleEnvironments can coexist (debug vs. release, host vs. target),Clone()d and locally tweaked, without global mutable state.Signatures over timestamps, by default. The default
Decideris content hashing. SCons stores per-target signature info in anSConsigndbm database (.sconsign.dblite) so an incremental run reloads prior signatures and rebuilds exactly the targets whose inputs (content, command line, or implicit header deps) actually changed.
How it works
The two phases: read the scripts, then walk the DAG
# One invocation does both phases:
scons # read SConstruct (+ all SConscripts), build the default targets
scons -j8 # ... with 8 parallel jobs
scons build/prog # build just one target by path
scons -c # "clean": remove the derived files SCons knows how to build
scons -n # dry run: print what WOULD be built, build nothingPhase 1 (read) executes SConstruct. Any Builder call (env.Program(...), env.Library(...)) does not build anything — it registers target/source Nodes and their edges in the graph and returns the target Nodes. SConscript(...) calls recurse into subdirectory scripts, accreting their nodes into the same global graph. Phase 2 (build) hands the graph to the Taskmaster, which walks it, asks each Node whether it is up to date (via its Decider), and dispatches the out-of-date ones to the scheduler.
Builders, Environments, and construction variables
A minimal SConstruct:
# SConstruct
env = Environment(CCFLAGS='-O2 -Wall', CPPPATH=['include'])
env.Program('hello', ['hello.c', 'util.c']) # -> builds ./hello (or hello.exe)env.Program is a Builder. It expands the command from construction variables — roughly $CC $CCFLAGS $CPPFLAGS $_CPPINCFLAGS -c -o $TARGET $SOURCES for the compile step and a link step for the program — using SCons's $-substitution language over the Environment dict. Because the configuration is Python, you can compute sources, branch on platform, and define your own Builders as first-class values:
debug = Environment(CCFLAGS='-g -O0', CPPDEFINES=['DEBUG'])
release = debug.Clone(CCFLAGS='-O2', CPPDEFINES=[]) # independent copy
for env, sub in [(debug, 'build/debug'), (release, 'build/release')]:
env.Program(f'{sub}/app', Glob(f'{sub}/*.c'))Implicit dependency scanning
SCons ships Scanners that parse source files to discover implicit dependencies the user never declared — most importantly the C/C++ preprocessor scanner that follows #include directives (respecting CPPPATH), plus Fortran, D, LaTeX, and SWIG scanners. The discovered headers become real graph edges, so editing a header rebuilds exactly the translation units that include it. This is the built-in replacement for make depend, and it is recomputed every run rather than cached into a stale .d file.
Signatures and the SConsign database
SCons distinguishes two hashes (man page, --hash-format):
- a content signature — "a hash of the contents of a file participating in the build (dependencies as well as targets)"; and
- a build signature — "a hash of the elements needed to build a target, such as the command line, the contents of the sources, and possibly information about tools used in the build."
The build signature is the content-address of a target: change the sources, the flags, or the command line and the build signature changes, forcing a rebuild (and becoming the key for caching, below). Signatures persist in .sconsign.dblite (the SConsign dbm), so a clean checkout's first incremental run knows precisely what is current. The hash algorithm is configurable — md5, sha1, or sha256 (--hash-format=sha256; the default has migrated from MD5 toward SHA-256 for FIPS environments).
The Decider: pluggable change detection
What "changed" means is a policy chosen with Decider() (SCons/Environment.py):
# SCons/Environment.py — Environment.Decider (abridged)
if function in ('MD5', 'content'):
function = self._changed_content # hash file contents (DEFAULT)
elif function in ('MD5-timestamp', 'content-timestamp'):
function = self._changed_timestamp_then_content # mtime gate, then hash
elif function in ('timestamp-newer', 'make'):
function = self._changed_timestamp_newer # Make-style: source newer than target
elif function == 'timestamp-match':
function = self._changed_timestamp_matchThe default 'content' is maximally correct but hashes every input; 'MD5-timestamp' is the common performance compromise — skip the hash when the mtime is unchanged, hash only when it moved — getting most of Make's speed with content-hash correctness on the files that actually changed. Decider('make') opts back into pure timestamp semantics.
The five dimensions
1. Workspace declaration & topology
Root marker = the
SConstructfile. A SCons "workspace" is the source tree rooted at the directory containingSConstruct(SCons searches the current and ancestor directories for it, likegitfinds.git). There is no member array and no glob of packages — contrast Cargo's[workspace] membersor pnpm'spnpm-workspace.yaml. The "members" are simply theSConscriptfiles the root chooses to pull in.Topology is an explicit
SConscripthierarchy. The root composes subdirectory scripts with theSConscript()function (User Guide ch. "Hierarchical Builds"):"The top-level
SConstructfile can use theSConscriptfunction to include other subsidiary scripts in the build. These subsidiary scripts can, in turn, use theSConscriptfunction to include still other scripts … By convention, these subsidiary scripts are usually namedSConscript."python# SConstruct — list the members explicitly SConscript([ 'drivers/display/SConscript', 'drivers/mouse/SConscript', 'parser/SConscript', 'utilities/SConscript', ])Membership is imperative and explicit: a directory is "in" the build only if some
SConscript()call names it (or names a parent that does). Because it isPython, you can also discover members dynamically —SConscript(Glob('libs/*/SConscript'))is a one-liner — but there is no first-class globbed-member declaration; you write thePythonthat finds them.One global DAG, not recursive sub-processes. Every
SConscriptaccretes nodes into the same in-memory graph in onesconsprocess. This is the structural cure for recursive-Make: cross-directory dependencies (aparserthat linksutilities) are first-class edges with correct ordering, not a fragile sub-make invocation order.Variant directories separate source from build.
VariantDir(build, src)(orSConscript(..., variant_dir=..., duplicate=...)) maps a source subtree onto a build subtree so multiple configurations (debug/release, per-arch) build the same sources into distinct output trees — the topology axis thatGN's out-dirs andCMake's build dirs also provide. Withduplicate=0SCons builds in place without copying sources; the defaultduplicate=1mirrors sources so generated files never pollute the source tree.
2. Dependency handling & isolation
No package manager, no registry, no lockfile. Like
GNandMake, SCons has no concept of fetching or versioning external packages — there is nodub.selections.json/Cargo.lockequivalent. Everything that participates in the build must already exist on disk (or be produced by the build). SCons finds system libraries and headers at configure time via theConfigurecontext (Autoconf-style feature probes:CheckLibWithHeader,CheckCHeader,CheckFunc), but it does not install them.Cross-references are graph edges and exported variables. A library built in
utilities/SConscriptis depended on fromparser/SConscriptby passing the returned targetNodeacross scripts. SCons threads variables between scripts withExport()/Import()/Return()(SCons/Script/SConscript.py):python# utilities/SConscript Import('env') libutil = env.Library('util', Glob('*.c')) Return('libutil') # hand the target Node back up # SConstruct env = Environment() Export('env') libutil = SConscript('utilities/SConscript') SConscript('parser/SConscript', exports={'env': env, 'libutil': libutil})There is no hoisting, no symlink tree, no virtual store (npm/pnpm concepts) and no
workspace:protocol (yarn-berry) — because there are no packages, onlyNodes in one graph. The graph edge is the cross-reference, and topological build order falls out of it automatically.Repositories: a shared source/derived tree (a proto-distributed-cache). The
Repository(dir)method (CLI-Y dir/--repository=dir) tells SCons to look in one or more central trees for source and derived files before building locally (User Guide ch. "Building From Code Repositories"):"It's often useful to allow multiple programmers working on a project to build software from source files and/or derived files that are stored in a centrally-accessible repository, a directory copy of the source code tree."
If a repository already contains an up-to-date derived file (validated by the same signature calculation, using the repository's
.sconsignfiles), SCons uses it instead of rebuilding locally — "SCons will perform its normal signature calculation to decide if a derived file in a repository is up-to-date, or if it needs to be rebuilt." This is SCons's pre-CacheDiranswer to sharing build output across a team, and the conceptual ancestor of a content-addressed cache.Isolation by
Pythonscoping, not sandbox. EachSConscriptruns in its own namespace and only sees what itImport()s — but actions are not sandboxed the wayBazel/Buck2sandbox them. Undeclared file reads are not prevented; correctness leans on the implicit scanner finding#includes and on the user declaring dependencies, not on hermetic enforcement.
3. Task orchestration & scheduling
One global
NodeDAG, walked by theTaskmaster. After phase 1 builds the graph, theTaskmaster(SCons/Taskmaster/__init__.py) "is the main engine for walking the dependency graph and calling things to decide what does or doesn't need to be built." It hands readyTasks to a scheduler and collects results, ordering strictly by the DAG.Change detection by content signature. Unlike
Ninja/Make(mtime), the defaultDeciderrebuilds aNodewhen its build signature changes — i.e. the hashed contents of its sources, its scanned implicit deps, or the command line differ from the storedSConsignvalue. Changing a compile flag rebuilds the affected objects even though no filemtimemoved; restoring an identical file does not trigger a rebuild even though itsmtimejumped. The'MD5-timestamp'decider adds anmtimefast-path to skip hashing unchanged files.Parallelism: the
NewParallelleader/follower scheduler.-j N/--jobs=Nruns independent DAG legs concurrently on a thread pool. As of4.7.0theNewParalleljob class (SCons/Taskmaster/Job.py) is the default for all builds. It is a leader/follower design: exactly one worker holds thetm_lock("ensures that we only have one thread interacting with the taskmaster at a time") and searches the graph for ready work, while followers wait on a condition variable and execute the tasks they are handed; completed tasks are retired off aresults_queueby the next thread to acquire the lock. The state machine isREADY → SEARCHING → STALLED → COMPLETED:python# SCons/Taskmaster/Job.py — NewParallel.State class State(Enum): READY = 0 SEARCHING = 1 STALLED = 2 COMPLETED = 3Two refinements landed with the default switch (
CHANGES.txt): the scheduler "only adds threads as new work requiring execution is discovered, up to the limit set by -j" (so shallow DAGs don't spin up idle threads), andCacheDirwrites no longer happen within the taskmaster critical section, so cache stores run in parallel with the DAG walk.Affected-target slicing is manual / partial. SCons has no first-class
--since <git-ref>affected-detection (Turborepo/Nx do; SCons does not). What it offers instead: (a) you build any subset by naming target paths on the command line (scons parser/), and (b) content signatures mean that even a fullsconsinvocation does no work for unchanged targets — the graph walk is cheap relative to compilation, so "build everything, rebuild nothing" is the default affected-detection. There is no built-in reverse-dependency query (gn refs-style) to compute the changed set from a diff; you'd script it.
4. Caching & remote execution
CacheDir: a local/shared content-addressed derived-file cache.CacheDir(dir)(SCons/CacheDir.py) caches every derived file keyed by its build signature. The cache path is literallydir/<sig-prefix>/<full-sig>(cachepath()usesnode.get_cachedir_bsig()and a configurableprefix_lensubdir fan-out) — a true content-addressed store. Before building a target, SConsretrieve()s it from the cache if a file under that signature exists; after building, it pushes the result in.The cache is shareable across a team over NFS. This is SCons's headline monorepo win and it predates the modern remote-cache era (User Guide ch. "Caching Built Files"):
"On multi-developer software projects, you can sometimes speed up every developer's builds a lot by allowing them to share a cache of the derived files that they build. … In environments where developers are using separate systems (like individual workstations) for builds, this directory would typically be on a shared or NFS-mounted file system."
Because the key is a content hash, a derived file another developer (or CI) already built is fetched instead of recompiled — the same value proposition as Turborepo's remote cache or
Bazel's action cache, achieved with a shared filesystem and no server.Cache control flags.
--cache-disable(ignore the cache this run),--cache-force/--cache-populate(push even targets that were retrieved, to seed a cache),--cache-readonly(retrieve but never write — the CI-builder pattern),--cache-show(print the would-be build command for cache hits), and a per-build--cache-debuglog. Recent hardening targets shared-cache races: SCons now uses auuid(not thepid) for the cache tmpfile and performs the cache-store behind an atomic rename to avoid two machines clobbering the same entry (CHANGES.txt).NOTE
CacheDiris a content-addressed cache over a filesystem, not a Remote Execution API (REAPI) client. SCons does not speak REAPI and has no built-in remote execution — it never ships compile actions to aBuildBuddy/Buildbarn/NativeLinkworker farm the wayBazel/Buck2do. Remote caching is "putCacheDiron a network filesystem"; remote execution is out of scope. (For a true REAPI story you would generateNinjaand use aNinja-RBE wrapper, or use SCons's--experimental=ninjaexport — see below.)Ninjaexport escape hatch. SCons can emit abuild.ninjafrom its graph (--experimental=ninja, theNINJAtool), so a project can keep SCons as the high-level generator but runNinja(and aNinja-level RBE wrapper) as a faster executor — bolting on the remote-execution path SCons itself lacks.
5. CLI / UX ergonomics
- One binary, one verb. Everything is
scons. No generate-then-build two-step (GN) and no subcommand zoo:sconsbuilds,scons -ccleans,scons -ndry-runs,scons -Qquiets the "Reading SConscript files…" chatter. The build description'sDefault(...)call sets what baresconsbuilds. - Target selection is by path/
Alias, not--filter. You scope a build by naming targets —scons build/app,scons utilities(anAlias), or a directory to build everything under it. There is no package selector like pnpm's--filteror Cargo's-p; the "package" granularity is the path/Aliasyou name.Alias('test', [...])thenscons testis the idiomatic task entry point (SCons has no built-intestphase — you wire it as an alias of an action). - Configuration via
Variables+ command-linekey=value. Build options are declared with theVariablessystem and overridden on the command line:scons debug=1 PREFIX=/opt.ARGUMENTS/ARGLISTexpose rawkey=valuepairs to thePython, so flag parsing is whatever you write —AddOption()even registers genuine new--long-options. - Diagnostics and introspection.
--tree=allprints the dependency tree;--debug=explainprints why each target is being rebuilt (which signature changed);--taskmastertrace=-dumps theTaskmaster's node-by-node decisions;-j Nsets parallelism;--randomshuffles build order to flush out missing dependencies. The introspection is rich because the graph is a livePythonobject, not an opaque generated file.
Strengths
- Real language, no DSL.
Pythonconfiguration means loops, functions, classes, helper modules, and arbitrary computation are free — noMakemacro contortions and no learning a bespoke config grammar. CustomBuilders andScanners are first-class. - Correct-by-default change detection. Content-hash build signatures (not
mtime) catch flag changes and ignore spurioustouches; the C/C++ scanner replacesmake depend. This is the reliabilityMakelacks. - One global DAG cures recursive-make. All
SConscripts build one in-memory graph in one process, so cross-directory dependencies and parallelism are always correct — no sub-make ordering bugs. - Shared content-addressed cache, decades early.
CacheDirover NFS gives a team a derived-file cache keyed by content hash — the modern remote-cache value proposition with zero server infrastructure.Repository/-Yadds a shared source+derived tree. - Variant directories and multiple
Environments make debug/release and multi-arch builds clean and parallel without polluting the source tree. - Mature, stable, broad. 25 years of production use (MongoDB, Blender precursors, Godot historically, many embedded toolchains); broad language/tool support; excellent
--debug=explainintrospection.
Weaknesses
- Historically slow at scale. Executing all
SConscripts up front and hashing contents is more work thanNinja'smtimecheck; large trees pay a noticeable graph-build + signature cost.NewParallel,MD5-timestamp, and theNinjaexport exist precisely to mitigate this, and SCons's own wiki has a longNeedForSpeedpage of tuning advice. - No package management / no lockfile. No fetch, no version resolution, no registry dependencies — you vendor or rely on system libraries probed by
Configure. No answer to Cargo/uv-style dependency resolution. - No remote execution and no REAPI. Remote caching is only "
CacheDiron a network share"; there is no engine-native action cache, no sandboxing, and no worker-farm execution likeBazel/Buck2. - No first-class affected-target /
--sinceslicing. No built-in reverse-dep query or git-diff-driven change set (Turborepo/Nx); you script it or rely on signatures making a full build cheap. - No sandboxing / hermeticity. Undeclared reads aren't prevented; correctness depends on scanners and discipline, so builds can be non-reproducible across environments.
- The
Python-is-the-config double edge. Full programmability invites slow, side-effecting, hard-to-analyzeSConstructfiles; there is no enforced "well-lit path" the wayGNis deliberately minimally expressive.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Configuration is Python (no DSL) | A real language solves real build problems; no grammar to learn; custom Builders/Scanners free | Slow, side-effecting configs are easy to write; no enforced legible "well-lit path" |
Content-hash build signatures by default (not mtime) | Reliable rebuilds: catches flag changes, ignores spurious touches | Hashing every input is slower than an mtime stat; mitigated by MD5-timestamp / NewParallel |
One global in-memory Node DAG (read all scripts, then build) | Cures recursive-make: correct cross-directory deps and parallelism in one process | Whole graph is built up front every run — a startup cost that grows with tree size |
Workspace = SConstruct root + explicit SConscript hierarchy | Zero ceremony; membership is whatever the scripts pull in (and is plain Python, so scriptable) | No first-class member array/glob or named sub-workspaces; you write the Python that finds members |
Cross-refs via graph edges + Export/Import/Return | No registry, no hoisting, no symlink store — the edge is the reference | No workspace: protocol, no isolation guarantees; sharing state is manual variable plumbing |
CacheDir = content-addressed derived-file cache over a filesystem | Team-wide build sharing keyed by content hash, no server needed; NFS-shareable | Filesystem-only (no REAPI); shared-cache races needed uuid/atomic-rename hardening |
NewParallel leader/follower scheduler, default for all builds | One thread touches the Taskmaster at a time; threads added lazily as work appears | A single critical section serializes graph search; throughput bounded by that lock for tiny tasks |
| No remote execution / sandbox / REAPI | Keeps SCons a self-contained Python tool; remote caching via a shared CacheDir | No hermeticity, no worker farm; for RBE you export Ninja and wrap the compiler externally |
| No package manager / lockfile / resolver | Stays a build tool; deps are on disk, probed by Configure | No registry-dependency story; vendoring or system libs only |
Decider is pluggable (content / MD5-timestamp / make) | Projects trade correctness for speed explicitly per their needs | Choosing make (timestamps) reintroduces the mtime unreliability SCons set out to fix |
Sources
SCons/scons— GitHub repository (engine + DocBook user guide)README.rst— "configuration files are Python scripts"; content-hash change detection; built-in C/C++/Fortran dependency analysisSCons/CacheDir.py— content-addressed cache keyed by build signature (cachepath/get_cachedir_bsig/retrieve)SCons/Taskmaster/__init__.py— the DAG-walking engine ("decide what does or doesn't need to be built")SCons/Taskmaster/Job.py—NewParallelleader/follower scheduler,tm_lock,READY/SEARCHING/STALLED/COMPLETEDstate machineSCons/Environment.py—Decider()(content/MD5-timestamp/timestamp-newer/timestamp-match)SCons/Script/SConscript.py—SConscript/VariantDir/Export/Import/Returndoc/user/caching.xml— shared NFSCacheDirfor multi-developer teamsdoc/user/repositories.xml—Repository/-Yshared source+derived treesdoc/user/hierarchy.xml— hierarchicalSConscriptbuildsCHANGES.txt—NewParallelmade default; lazy thread spawn;CacheDirwrites outside the critical section; shared-cacheuuid/atomic-rename hardening- SCons User Guide (rendered) ·
sconsman page (signatures, hash format, cache/decider/-j/-Yoptions) · Releases - Sibling deep-dives:
GN+Ninja· Bazel · Buck2 · Cargo · Turborepo · Nx · pnpm · npm · Yarn Berry · Go (go.work) · uv;Make,Meson,CMake,Waf,Ninja,redo,tup(sibling native systems); REAPI backendsBuildBuddy/Buildbarn/NativeLink; the umbrella survey index and the D async/dublandscape