Skip to content

Pants (Polyglot) ​

A scalable polyglot build orchestrator for monorepos whose defining bet is dependency inference: instead of hand-written dependency edges (the Bazel / Buck2 model), Pants reads your import statements with per-language static analysis, builds a fine-grained file-level target graph, and executes it through a memoizing Rust + Tokio-async engine that caches every process precisely by its inputs — locally or over the Bazel Remote Execution API.

FieldValue
LanguageRust (engine core) + typed Python 3 (rules / plugin API); Starlark-like BUILD files
LicenseApache-2.0
Repositorypantsbuild/pants
Documentationpantsbuild.org · How does Pants work? · Targets & BUILD files
CategoryPolyglot Build Orchestrator
Workspace modelSingle repo rooted at pants.toml; the whole tree is one workspace of BUILD-file packages, sliced by source roots
First releasedv1 open-sourced out of Twitter, Sept 2014 (Scala/JVM era); v2 (the current Rust-engine rewrite) GA 2020
Latest release2.32.0 (May 28, 2026); 2.31.0 (Feb 19, 2026) was the prior stable line

Latest release: 2.32.0, released May 28, 2026. Pants ships on a roughly quarterly 2.x cadence (2.27 Jun 2025, 2.28 Sep 2025, 2.31 Feb 2026, 2.32 May 2026). Note 2.31 dropped Intel macOS (x86_64) binaries. Citations below are against the main branch and the dev documentation channel as of June 5, 2026.


Overview ​

What it solves ​

Pants targets the same problem as Bazel and Buck2 — one repository containing many projects in many languages, built and tested as a single coherent graph — but attacks the adoption cost that makes those tools heavy. The README states the scope precisely:

"Pants is a scalable build system for monorepos: codebases containing multiple projects, often using multiple programming languages and frameworks, in a single unified code repository." — README.md

Where Bazel makes you write and maintain the dependency graph by hand (deps = ["//mathlib"] on every rule), Pants' wager is that the graph is already encoded in your source — Python imports, Go imports, JVM imports, TypeScript imports, protobuf imports — and a build tool should read it rather than make you restate it. That single decision cascades through the whole design: BUILD files shrink to near-empty metadata stubs (often auto-generated), and the engine still gets a precise, fine-grained graph to invalidate and cache against. The official framing:

"Pants is designed to be easy to adopt, use, and extend. It doesn't require you to refactor your codebase or to create and maintain massive amounts of build metadata." — Welcome to Pants

The README enumerates the seven pillars that follow from that goal:

  1. Explicit dependency modeling (inferred, then materialized as a graph).
  2. Fine-grained invalidation.
  3. Shared result caching.
  4. Concurrent execution.
  5. Remote execution.
  6. Unified interface for multiple tools and languages.
  7. Extensibility and customizability via a plugin API.

Conceptually Pants sits between the language-specific package managers (Cargo, uv, npm) — which own dependency resolution for one language but cannot orchestrate a polyglot graph — and the heavyweight hermetic engines (Bazel, Buck2) that orchestrate any language but demand fully hand-authored build files. It overlaps most with Please (another inference-leaning Go-implemented engine) and the simpler task graphs of Nx / Turborepo.

Design philosophy ​

The headline architectural choice is a two-language engine: a Rust core for speed, Python rules for extensibility.

"The Pants engine is written in Rust, for performance. … The build rules that it uses are written in typed Python 3, for familiarity and simplicity." — How does Pants work?

The Rust core is built on Tokio — the engine "can take full advantage of all the cores on your machine," running lint, type-check, and test legs concurrently across the discovered graph. Three consequences shape the entire tool:

  1. The graph is a memoized pure-function evaluation. Build logic is a set of @rules: typed Python async functions whose await Get(Output, Input) calls form an implicit dependency graph the Rust engine schedules. The engine "caches processes precisely based on their inputs" — every node is a pure function of its declared inputs, so the same inputs always hit the cache. This is the same correctness discipline as Bazel's action graph, reached by a different authoring path.
  2. Hermetic, sandboxed process execution. Each external process (a pytest run, a go build, a mypy invocation) executes in a sandbox containing only its declared inputs. Hermeticity is what makes the cache key trustworthy and what makes remote execution a near-free extension of local execution.
  3. A long-lived daemon keeps the graph warm. pantsd watches the filesystem and keeps the build graph and rule memoization resident between runs, so an edit invalidates only the reverse-transitive closure of the changed file rather than forcing a cold re-evaluation.

The unifying thesis, from the docs, is that incrementality and distribution are emergent rather than bolted-on:

"…fine-grained invalidation, concurrency, hermeticity, caching, and remote execution happen naturally" — Welcome to Pants


Core abstractions and types ​

ConceptName / syntaxRole
Workspace rootpants.toml at the repo rootThe single boundary marker; everything below is one workspace
Package metadataBUILD filesPer-directory target declarations (often auto-generated by tailor)
Targetpython_sources, go_binary, pex_binary, docker_image, …Addressable metadata describing code or an artifact
Atom (file-level) targetpath/to/file.py:tgtOne generated target per source file — the unit of inference & invalidation
Target generatorpython_tests, files, go_modOne declaration → many generated per-file/per-package targets
Target addresspath/to/dir:name, //:root_tgt, :siblingThe unique handle; // is the build root
Source root[source].root_patterns / marker_filenamesDirectory where a language's import namespace begins
Resolve[python.resolves] name → lockfile pathA named third-party dependency universe (its own lockfile)
Goaltest, lint, fmt, check, package, run, replThe verb / command boundary; pants <goal> <specs>
Rule@rule async def …A typed Python node in the engine graph (await Get(Out, In))
Backend[GLOBAL].backend_packagesA pluggable language/tool module (pants.backend.python, …go, …javascript)
DaemonpantsdFilesystem-watching process keeping the graph + memo cache warm
Engine coresrc/rust/engine (Rust + Tokio)Schedules and memoizes the rule graph; runs sandboxed processes
Local store~/.cache/pants/lmdb_storeContent-addressed local CAS + action cache (LMDB)

How it works ​

A Pants invocation is pants <goals> <specs> — e.g. pants test :: ("test everything") or pants lint check src/python::. The engine resolves the specs to a set of targets, expands each goal into a rule subgraph, infers the dependency edges between targets from their source imports, schedules the resulting node graph across cores, executes every external tool in a sandbox, and memoizes each node by the content hash of its inputs.

A minimal root config selecting a couple of language backends:

toml
# pants.toml
[GLOBAL]
pants_version = "2.32.0"
backend_packages = [
    "pants.backend.python",
    "pants.backend.experimental.go",
]

[source]
root_patterns = ["/src/python", "/src/go"]

[python]
enable_resolves = true
default_resolve = "python-default"

[python.resolves]
python-default = "3rdparty/python/default.lock"

A BUILD file is typically tiny because inference fills in dependencies:

python
# src/python/myorg/app/BUILD
python_sources()          # generates one python_source target per .py file here
pex_binary(name="bin", entry_point="main.py")

# src/python/myorg/app/BUILD  — note: no `dependencies=[...]` needed

The canonical way to author/refresh BUILD files is not by hand but via the tailor goal, which scans for unowned source files and writes the minimal target stubs for them:

bash
pants tailor ::          # auto-create/update BUILD targets across the whole repo

Dimension 1 — Workspace declaration & topology ​

Pants has no explicit member array and no glob of sub-packages. The workspace is the single repository rooted at pants.toml; topology is implicit in the directory tree, where every directory containing a BUILD file is a package, and the language-import namespace is anchored by source roots. This is closer to Bazel's implicit-tree model than to the explicit members = [...] arrays of Cargo or pnpm.

Source roots are the topology primitive. They are declared two ways (Source roots):

toml
# pattern-based: paths that are roots of an import hierarchy
[source]
root_patterns = ["/src/python", "/test/python", "/src/*"]

# OR marker-file based:
[source]
marker_filenames = ["SOURCE_ROOT", "BUILD_ROOT"]

"Place a file of that name in each of the source roots. The contents of those files don't matter." — Source roots

Source roots translate filesystem layout into import namespaces: with /src/python a source root, src/python/project/app.py imports as from project.app import App. Because patterns support relative suffixes (src/python matches any directory ending that way) and globs (/src/*), a monorepo can mix per-language top-level trees (src/java, src/go) and project-local roots (via marker files) in the same repository without reorganizing code — the explicit anti-refactor stance from the design philosophy.

NOTE

The unit of addressing is finer than a directory. Pants generates a target per file (the "atom" target path/to/file.py:gen), which is what lets inference and invalidation operate at file granularity rather than at the coarse package granularity of most language package managers.

Dimension 2 — Dependency handling & isolation ​

This is where Pants diverges most sharply from every neighbor. There are two distinct dependency layers, handled differently:

First-party (intra-repo) edges are inferred, not declared.

"Usually, you leave off the dependencies field thanks to dependency inference. Pants will read your import statements and map those imports back to your first-party code and your third-party requirements." — Targets & BUILD files

So a local cross-reference between two members is automatic: importing from myorg.lib import x creates a graph edge to the target owning myorg/lib. There is no workspace: protocol (pnpm / Yarn), no path = "../lib" (Cargo), and no //lib label (Bazel) to write — the import is the edge.

Third-party deps use named resolves + lockfiles, with no global hoisting. A resolve is a named dependency universe backed by its own lockfile (Lockfiles):

toml
[python]
enable_resolves = true
default_resolve = "python-default"

[python.resolves]
python-default = "3rdparty/python/default.lock"
django3      = "3rdparty/python/django3.lock"
django4      = "3rdparty/python/django4.lock"

A lockfile "enumerates specific pinned versions of every transitive third-party dependency" with SHA256 hashes; pants generate-lockfiles (delegating to Pex for Python) produces them. Each target picks its universe via a resolve= field, and "all transitive dependencies of a source target must use the same resolve" — so multiple incompatible versions (Django 3 and Django 4) can coexist in one repo as separate resolves, the multi-version story that flat hoisting (npm) cannot express.

Isolation is per-process, not per-tree: there is no shared node_modules hoist and no symlink farm. Each sandboxed process gets only the requirements it transitively uses:

"…when running a test, only the requirements actually used (transitively) by that test will be present on the sys.path." — Lockfiles

This dependency subsetting is both a correctness property (a sandbox cannot see undeclared deps) and a cache property (a change to an unrelated requirement does not invalidate this test's action key).

Dimension 3 — Task orchestration & scheduling ​

Pants builds a true rule DAG, not a fixed task list. The Rust engine (src/rust/engine) treats every @rule as a memoized node; a rule's await Get(Output, Input) calls are its outgoing edges. A goal like test expands into a subgraph rooted at the requested targets, which in turn pull in their inferred first-party dependencies and the processes needed to compile, resolve, and run them. The engine then:

  • Schedules concurrently. Independent legs (lint vs. type-check vs. test of unrelated targets) run in parallel across all cores via Tokio. The Tokio move was deliberate — the engine separates "control of concurrent filesystem access from process execution," (PR #5846).
  • Invalidates fine-grained. pantsd watches the filesystem; an edit invalidates only the memo nodes whose inputs changed and their reverse-transitive dependents. Everything else stays warm in the daemon.
  • Keys on content, not timestamps. A process's cache key is the content hash of its sandbox inputs (sources, tool, args, env). Identical inputs ⇒ cache hit, the precondition for a shared cache being correct.

This is the same "pure function of declared inputs" invariant that makes Bazel's action cache sound — but Pants reaches it without the author declaring the inputs, by deriving them from imports + the rule graph.

Dimension 4 — Caching & remote execution ​

Caching is layered:

  • Local memoization — within and across runs (warm in pantsd).
  • Local persistent cache — a content-addressed store on disk (~/.cache/pants/lmdb_store, an LMDB-backed CAS + action cache). Default behavior is local-only: "Pants executes locally and caches results locally by default."
  • Remote caching & remote execution over REAPI, the same standard protocol Bazel uses:

"Pants is compatible with remote caching and remote execution servers that comply with the Remote Execution API standard ('REAPI')." — Remote caching & execution

A REAPI server exposes three services, which Pants maps onto its own vocabulary: a content-addressable storage ("store server"), an action cache, and an execution service ("execution server"). A remote cache implements CAS + action cache; a remote executor implements all three. Compatible self-hosted backends include BuildBarn, Buildfarm, and BuildGrid for full execution, plus bazel-remote-cache (local disk, S3, GCS, Azure Blob) for CAS-only caching — the very same REAPI backend universe shared with Buildbarn and NativeLink.

Enabling a shared cache is a few [GLOBAL] keys:

toml
[GLOBAL]
remote_cache_read  = true
remote_cache_write = true
remote_store_address = "grpc://build.corp.example.com:8980"   # grpcs:// for TLS
remote_instance_name = "main"

Remote execution adds remote_execution = true and a remote_execution_address. Because every process is already hermetic and keyed by input content, remote execution is a near-transparent substitution of "run this action on a farm" for "run it locally" — the hermeticity invested for caching pays for distribution too.

Dimension 5 — CLI / UX ergonomics ​

The command boundary is pants <goals...> <specs...>. Goals are the verbs (test, lint, fmt, check, package, run, repl, dependencies, list, peek, paths); specs are the noun set.

Spec algebra (Advanced target selection):

Spec formSelects
::All targets in the entire repo (recursive)
dir::All targets under dir (recursive)
dir:All targets in dir (non-recursive)
path/to/file.pyThe target(s) owning that file
dir:nameOne specific target by address
-dir/ignore::Exclude a subtree (leading -)

Crucially, you can pass files and directories directly — pants test src/python/app/test_foo.py — and Pants maps the file back to its owning target. The docs make this the headline ergonomic: you "invoke it directly on source files and directories, so it doesn't require users to adopt a new conceptual model" (Welcome to Pants). Compare Bazel's mandatory //pkg:tgt label algebra.

Affected-target / change detection is first-class via git:

"pants can find which files have changed since a certain commit through the --changed-since option." — Advanced target selection

bash
pants --changed-since=origin/main lint                       # only changed files
pants --changed-since=HEAD~1 --changed-dependents=transitive test

--changed-dependents=direct|transitive widens the set from the changed files to their dependents — the affected-set CI optimization that Nx, Turborepo, and Bazel's rdeps/target-determinator provide, built into the core CLI here rather than as a side tool.

Filtering and sharding layer on top: --filter-target-type=python_test, --filter-address-regex=…, --filter-tag-regex=… (comma = OR, repeat = AND, - prefix = NOT), --tag/--tag='-…' against each target's tags field, --spec-files=<file> for a centralized allowlist, and native test sharding via --test-shard=k/N. Reusable verb+flag combos go in [cli.alias] in pants.toml.


Strengths ​

  • Dependency inference erases the biggest Bazel/Buck2 adoption cost. No hand-written deps; BUILD files are generated by tailor and stay tiny. The graph still ends up fine-grained and precise.
  • Polyglot from one interface. A single pants test :: covers Python, Go, JVM (Java/Scala/Kotlin), JS/TS, Shell, Docker, protobuf — composing each language's standard tools into one hermetic toolchain.
  • Correct shared caching by construction. Hermetic, content-keyed processes make local, remote-cache, and remote-execution results interchangeable — REAPI-compatible, so it reuses the BuildBarn/bazel-remote farm ecosystem rather than inventing one.
  • File-level granularity + warm daemon. pantsd keeps the graph resident; edits invalidate only the reverse-transitive closure of the touched file.
  • Affected-set CI is built in. --changed-since + --changed-dependents need no external determinator tool.
  • Extensible in plain typed Python. Rules are async Python functions over a typed Get/Rule API — a far lower barrier than authoring Starlark rules.
  • Ergonomic noun model. Pass files/dirs directly; no mandatory label syntax.

Weaknesses ​

  • Inference is per-language and imperfect. Dynamic imports, runtime plugin discovery, resources, and non-import file dependencies need explicit dependencies entries or fail silently — the convenience has sharp edges.
  • Heavier and slower to cold-start than minimalist runners (Task, Just, Turborepo); the Rust engine + daemon + Python rule graph is real machinery (though 2.28 cut daemon startup to ~30–40%).
  • One conceptual universe per language, parallel to native tooling. Resolves
    • lockfiles are a Pants-managed dependency world living alongside (and sometimes duplicating) each language's own resolver — the same critique levelled at Bazel's Bzlmod.
  • "All transitive deps share one resolve" is a real constraint: mixing two versions of a library means partitioning targets into separate resolves.
  • Smaller ecosystem & community than Bazel. Fewer pre-built rules for exotic languages; more reliance on the (good) built-in backends.
  • Platform trimming. 2.31 dropped Intel-macOS binaries — an operational gotcha for mixed fleets.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Dependency inference from imports (vs. hand-written deps)Eliminates the dominant adoption cost of Bazel/Buck2; BUILD files stay near-emptyInference is per-language and misses dynamic/non-import edges; needs occasional explicit deps
Rust engine core + typed Python @rule plugin APIRust+Tokio for parallel performance; Python for an approachable, extensible rule languageTwo-language internals; a rule-graph indirection that minimalist runners don't carry
File-level (atom) targets generated per source fileFile-granular inference, invalidation, and cachingMany more graph nodes than package-granular tools; BUILD generators needed to manage them
Implicit directory-tree topology rooted at pants.tomlNo member arrays/globs to maintain; the tree is the workspaceLess explicit than members = [...]; topology is discovered, not declared
Hermetic, content-keyed sandboxed processesMakes local, remote-cache, and remote-execution results interchangeable and correctUndeclared inputs are errors; sandboxing has overhead vs. running tools in-place
Named resolves + lockfiles, dependency subsetting (no hoist)Multiple conflicting third-party versions coexist; unrelated dep changes don't invalidateA Pants-managed dependency universe parallel to each language's resolver; one-resolve-per-graph
REAPI for remote cache + execution (reuse Bazel's protocol)Plug into existing BuildBarn/bazel-remote/Buildfarm farms; no bespoke backendOperational complexity of running CAS/action-cache/executor infra; pays off only at scale
pantsd daemon keeps the graph + memo warmFine-grained invalidation; fast incremental edit-loopA long-lived process to manage; cold start still non-trivial
Goals + file/dir specs + --changed-since as the CLI boundaryPass source files directly; affected-set CI without an external determinatorA larger spec/filter vocabulary than a simple -p name

Sources ​