Taming the Python Monorepo: A Practical Guide to Aspect Bazel
Your Python monorepo has 12 apps, 3 shared libraries, and a CI pipeline that takes 22 minutes to validate a two-line change. Every developer on the team runs pip install -r requirements.txt manually. Your Docker images are built by shell scripts that nobody fully understands. And the words "it works on my machine" appear in Slack at least twice a week.
If any of that sounds familiar, this is for you.
The Real Problem With Python Monorepos at Scale
Python’s default build model was designed for single-project setups. Virtual environments, pip, and even modern tools like Poetry and uv work well when you have one app to run. They break down when you have ten.
At scale, Python monorepos suffer from four specific failure modes:
No build graph awareness. pip install does not know which app depends on which library. If shared/auth.py changes, you have no reliable way to know which downstream apps need to be retested without running everything.
No hermeticity. Two developers with different OS versions, Python patch releases, or pip caches get different builds. Bugs are unreproducible. CI passes on main; staging is broken.
No incremental builds. CI rebuilds and retests everything on every push, even when 90% of the code is unchanged. You pay for the full matrix every time.
Container builds are manual tax. Writing and maintaining per-app Dockerfiles that track your Python dependency graph is toil. They drift from the source, and nobody has time to fix them until something breaks in production.
Bazel was designed to solve exactly these problems. But vanilla Bazel has its own reputation: steep learning curve, Starlark everywhere, confusing workspace setup, and a developer experience that can feel like punishment.
That is where Aspect Bazel comes in.
What Is Aspect Bazel?
Aspect Bazel is a suite of open-source tools and rulesets built on top of Google’s Bazel that significantly improve the developer experience for real-world monorepos. It consists of three main layers:
- Aspect CLI (
aspect): A drop-in replacement for thebazelCLI that adds commands likeaspect lint,aspect format, andaspect configure-- features that vanilla Bazel does not include. - Aspect Rules: A collection of well-maintained Bazel rulesets for JavaScript (
aspect_rules_js), TypeScript (aspect_rules_ts), Python (aspect_rules_py), linting (aspect_rules_lint), and general utilities (aspect_bazel_lib). These are more ergonomic and better maintained than the Google-provided equivalents. - Aspect Workflows (optional, hosted): CI/CD integration for faster remote caching and dynamic worker allocation. This post focuses on the open-source local setup.
The key shift: Aspect handles the boilerplate so your team can focus on the build graph for your actual project.
Getting Started: MODULE.bazel and bzlmod
Modern Aspect Bazel uses bzlmod — Bazel’s native dependency management system introduced in Bazel 6, instead of the older WORKSPACE file approach.
Here is what a real MODULE.bazel looks like for a Python monorepo with multiple web apps:
module(
name = "my_monorepo",
version = "0.0.1",
)
bazel_dep(name = "aspect_bazel_lib", version = "2.9.1")
bazel_dep(name = "aspect_rules_lint", version = "1.0.3")
bazel_dep(name = "aspect_rules_js", version = "2.1.0")
bazel_dep(name = "aspect_rules_ts", version = "3.2.1")
bazel_dep(name = "aspect_rules_py", version = "0.9.1")
bazel_dep(name = "rules_python", version = "0.37.1")
bazel_dep(name = "rules_python_gazelle_plugin", version = "0.37.1")
bazel_dep(name = "rules_uv", version = "0.35.0")
bazel_dep(name = "rules_oci", version = "1.7.4")
bazel_dep(name = "buildifier_prebuilt", version = "7.3.1")Each bazel_dep is pinned and hash-verified. No more requirements.txt drift between developers. Bazel fetches exactly these versions, builds them in a sandbox, and caches the result. When a teammate updates a dep, CI and every local machine pick it up automatically on the next build.
Pinning a Hermetic Python 3.12 Toolchain
One of the most valuable things Aspect Bazel brings to Python teams is hermetic interpreter management. Add this to MODULE.bazel:
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
python.toolchain(
python_version = "3.12",
)Bazel now downloads and uses a specific, verified Python 3.12 binary for every build action. No more “works with 3.12.3 but breaks on 3.12.1” issues. Every developer, CI runner, and container gets the same interpreter.
Declaring pip Dependencies with bzlmod
Managing third-party pip dependencies in Bazel bzlmod works through the pip extension:
pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
pip.parse(
hub_name = "pip",
python_version = "3.12",
requirements_lock = "//:requirements/all.txt",
)
use_repo(pip, "pip")Your requirements/all.txt is a fully pinned lockfile generated by pip-compile or uv. Bazel reads it, downloads the wheels once, and makes them available as @pip//package_name targets.
To add a new dependency:
# 1. Add it to pyproject.toml
# 2. Regenerate the lockfile
./tools/repin
# 3. Reference it in your BUILD.bazel
deps = ["@pip//reflex", "@pip//sqlmodel"]No more pip install in CI scripts. No more docker layers that run pip install at container build time. Dependencies are declared, locked, and built hermetically.
BUILD.bazel: Declaring Your App’s Build Graph
Each app in the monorepo gets a BUILD.bazel file. Here is what a real one looks like for a Reflex web app:
load("@aspect_rules_py//py:defs.bzl", "py_library")
load("//bazel_rules:reflex_run.bzl", "reflex_run")
load("//bazel_rules:reflex_db_run.bzl", "reflex_db_run")
load("//bazel_rules:reflex_full_app_image.bzl", "reflex_full_app_image")
filegroup(
name = "assets",
srcs = glob(["assets/**/*"]),
visibility = ["//:__subpackages__"],
)
py_library(
name = "web_app",
srcs = [
"__init__.py",
"rxconfig.py",
],
data = [":assets"],
visibility = ["//:__subpackages__"],
deps = ["@pip//reflex"],
)
# Run the app locally
reflex_run(
name = "web_app_run",
)
# Run database migrations
reflex_db_run(
name = "web_app_db_run",
)
# Build and push a production container image
reflex_full_app_image(
name = "web_app_full_image",
api_url = "https://my-app.example.com",
data = [":assets"],
port = "8080",
reflex_run = ":web_app_run",
repository = "123456789012.dkr.ecr.us-west-2.amazonaws.com/web_app",
)Run the app with:
aspect run //apps/web_app:web_app_runBuild and push its container image with:
aspect run //apps/web_app:web_app_full_image.pushBazel builds the image layer by layer, using its action cache. If nothing changed in the app’s source or dependencies since the last build, the push is nearly instant — Bazel skips every unchanged layer.
Writing Custom Bazel Rules
The real power of Bazel emerges when you write custom rules that encode your organization’s conventions. Instead of each app having its own Dockerfile, startup script, and run command, you write a rule once and every app uses it.
Get Rahul Bagai’s stories in your inbox
Join Medium for free to get updates from this writer.
Here is the pattern for reflex_run, a custom rule that handles running a Reflex web app correctly under Bazel:
def _reflex_run_impl(ctx):
reflex_bin = ctx.executable._reflex_tool
package_path = ctx.label.package
script_content = """
#!/usr/bin/env bash
set -eo pipefail
WORKSPACE_DIR="/workspace/my_monorepo"
ACTUAL_APP_DIR="${WORKSPACE_DIR}/%s"
cd "${ACTUAL_APP_DIR}" || exit 1
export DB_URL="sqlite:///${WORKSPACE_DIR}/%s/${app_name}.db"
export REFLEX_DB_URL="$DB_URL"
exec %s run --loglevel debug
""" % (package_path, db_dir, reflex_bin.short_path)
# ... create executable and return DefaultInfoThe reflex_full_app_image rule goes further. It handles:
- Collecting transitive Python source files across the dependency graph
- Generating a deterministic
requirements.txtfrom Bazel's dep graph (not a hand-maintained file) - Injecting build-time and runtime environment variables
- Replacing API URL placeholders at container startup via a shell script
- Pushing to ECR with
rules_oci
All of this is defined once in bazel_rules/reflex_full_app_image.bzl. Every app in the monorepo gets container builds for free by declaring:
reflex_full_app_image(
name = "my_app_full_image",
repository = "123456789.dkr.ecr.us-west-2.amazonaws.com/my_app",
port = "8080",
reflex_run = ":my_app_run",
)Aspect CLI: The Developer Experience Layer
The aspect CLI adds ergonomic commands that vanilla bazel does not have.
Format code across the entire repo:
aspect run format
# or format a specific file:
aspect run format apps/web_app/states/auth_state.pyLint with inline fix suggestions:
aspect lint //...This uses aspect_rules_lint, which runs your linters (ruff, mypy, eslint) as Bazel actions. Results are cached. If you lint a file and then lint it again without changes, the result comes from cache instantly.
Auto-update BUILD files after adding a new Python import:
aspect configureThis runs Gazelle, which scans your Python source files, detects new imports, and updates the deps lists in your BUILD.bazel files automatically. You almost never need to touch BUILD files by hand.
Makefile shortcuts for common workflows:
my-app-run:
aspect run //apps/my_app:my_app_run
my-app-test-run:
aspect test //apps/my_app/tests/... \
--nocache_test_results --test_output=allThe Makefile wraps aspect commands so developers can run make my-app-run without memorizing Bazel target syntax.
What You Actually Get
After setting this up, here is what changes for your team:
Incremental CI. Bazel’s remote cache means a CI run that touches only one app retests only that app and its dependents. A 22-minute full rebuild becomes a 2-minute targeted test run.
Reproducible builds. Every developer, every CI runner, every container uses the same hermetic Python 3.12 toolchain and the same pinned pip deps. “Works on my machine” becomes “works everywhere.”
Container builds without Dockerfiles. Custom reflex_full_app_image rules generate production-ready OCI images directly from the Bazel build graph. No Dockerfiles to maintain. No drift between the build graph and the container.
Codebase-aware testing. Because Bazel knows your dependency graph, aspect test //... will only rerun tests for targets that are actually affected by a change. Your test suite scales with the monorepo without the CI bill scaling with it.
Consistent tooling. aspect run format, aspect lint, and aspect configure give every developer the same formatter, linter, and BUILD file generator. Onboarding a new engineer means running one command, not a 10-step setup guide.
Getting Started
The fastest path to Aspect Bazel for a Python monorepo:
- Install the Aspect CLI:
brew install aspect-build/aspect/aspect(or grab a binary from github.com/aspect-build/aspect-cli) - Bootstrap
MODULE.bazelwithaspect_bazel_lib,rules_python, andaspect_rules_py - Add your Python toolchain and pip deps as shown above
- Run
aspect configureto auto-generate your initialBUILD.bazelfiles - Add
aspect_rules_lintand wire uprufforflake8as linters
The Aspect documentation at docs.aspect.build covers each step in depth. The aspect_rules_py README is particularly useful for the Python-specific setup, including hermetic interpreter configuration and pip integration with bzlmod.
If you need container image builds, rules_oci (also from the Aspect ecosystem) is the modern replacement for the older rules_docker and integrates cleanly with the rest of the stack.
Closing Thought
Bazel has a reputation for being hard. That reputation is earned when you fight the framework. Aspect Bazel leans into the parts that are genuinely powerful — the build graph, the action cache, hermetic toolchains, and papers over the parts that are needlessly painful — workspace setup, CLI ergonomics, linting integration.
For teams serious about monorepo scalability, the upfront investment in Bazel pays back in every CI run, every reproducible build, and every onboarding that does not require a three-hour environment setup.
The build system should be invisible infrastructure, not a tax. Aspect makes that possible.






