Sitemap
Rahul Bagai

Rahul Bagai

Rahul Bagai is an AI SaaS founder with 15+ years of experience building products at the intersection of enterprise software and AI.

Taming the Python Monorepo: A Practical Guide to Aspect Bazel

7 min readMay 16, 2026

--

Press enter or click to view image in full size
Bazel + Python

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:

  1. Aspect CLI (aspect): A drop-in replacement for the bazel CLI that adds commands like aspect lint, aspect format, and aspect configure -- features that vanilla Bazel does not include.
  2. 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.
  3. 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_run

Build and push its container image with:

aspect run //apps/web_app:web_app_full_image.push

Bazel 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 DefaultInfo

The reflex_full_app_image rule goes further. It handles:

  • Collecting transitive Python source files across the dependency graph
  • Generating a deterministic requirements.txt from 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.py

Lint 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 configure

This 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=all

The 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:

  1. Install the Aspect CLI: brew install aspect-build/aspect/aspect (or grab a binary from github.com/aspect-build/aspect-cli)
  2. Bootstrap MODULE.bazel with aspect_bazel_lib, rules_python, and aspect_rules_py
  3. Add your Python toolchain and pip deps as shown above
  4. Run aspect configure to auto-generate your initial BUILD.bazel files
  5. Add aspect_rules_lint and wire up ruff or flake8 as 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.

--

--

Rahul Bagai
Rahul Bagai