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 Buck2one 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 directlypants 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