Python Monorepo Management — Deep Dive
Dependency Resolution in Workspace Mode
The core technical challenge of a Python monorepo is dependency resolution across packages that reference each other. Unlike JavaScript’s npm workspaces, Python lacked native workspace support until recently. The ecosystem now has several mature options.
uv Workspaces
uv’s workspace feature (stable since 0.5) uses a root pyproject.toml to declare members:
# Root pyproject.toml
[tool.uv.workspace]
members = ["packages/*"]
Each member’s pyproject.toml can reference siblings as path dependencies:
# packages/auth-service/pyproject.toml
[project]
name = "auth-service"
dependencies = [
"shared-models", # resolved from workspace
"fastapi>=0.110",
]
[tool.uv.sources]
shared-models = { workspace = true }
Running uv sync from the root creates a single lockfile (uv.lock) that resolves all workspace members and their transitive dependencies together. This prevents version conflicts where auth-service wants pydantic==2.6 but billing-service pins pydantic==2.5.
Hatch Workspaces
Hatch takes a similar approach with its workspace table. The key difference is that Hatch manages virtual environments per project by default, while uv creates one shared environment (or per-package environments with --isolated).
# hatch.toml at root
[workspace]
members = ["packages/*"]
Hatch environments can be scoped: hatch run packages/auth-service:test runs tests only for that service, using its declared dependencies.
Build Systems: Pants for Python Monorepos
When a workspace has more than ~30 packages, path-filter CI breaks down because transitive dependencies become complex. Pants solves this with a dependency graph.
How Pants Works
Pants reads BUILD files in each directory and infers dependencies from Python import statements. When you change shared_models/user.py, Pants traces every file that imports from shared_models and schedules only those tests:
# Run tests affected by changes since main branch
pants --changed-since=main --changed-dependents=transitive test
BUILD File Example
# packages/auth-service/BUILD
python_sources(name="lib")
python_tests(
name="tests",
dependencies=[
"packages/shared-models:lib",
],
)
pex_binary(
name="server",
entry_point="auth_service.main:app",
)
Fine-Grained Caching
Pants caches test results by content hash. If test_login.py and all its dependencies are unchanged, Pants reports the cached result without re-running. For teams with 1000+ tests, this cuts CI from 20 minutes to under 2 minutes on average.
Remote Caching
Pants supports remote cache backends (S3, GCS, or custom gRPC endpoints). When developer A runs tests locally and pushes, developer B and CI both hit the cache and skip redundant work. Setup requires a pants.toml entry:
[GLOBAL]
remote_cache_read = true
remote_cache_write = true
remote_store_address = "grpc://cache.internal:8980"
Versioning Strategies
Independent Versioning
Each package has its own version in pyproject.toml. Releases happen per-package when changes land. This is the most flexible approach but requires discipline — internal consumers must explicitly declare version ranges.
Lockstep Versioning
All packages share one version number. Every release bumps all packages, even those with no changes. Simple to reason about, but creates noise — consumers see version bumps with empty changelogs.
Hybrid: Tag-Based
Use git tags like auth-service/v1.3.0 and shared-models/v2.1.0. CI detects which tags are new and publishes only those packages. Tools like python-semantic-release can be configured per-package directory.
Publishing from a Monorepo
Private Registry
For internal packages, publish to a private registry (Artifactory, CodeArtifact, GitLab Package Registry):
# Build and publish one package
cd packages/shared-models
uv build
uv publish --repository internal
Workspace-Aware Publishing
A CI job can iterate over changed packages:
# GitHub Actions example
- name: Publish changed packages
run: |
for pkg in $(pants --changed-since=${{ github.event.before }} list --type=target | grep pex_binary); do
dir=$(echo $pkg | cut -d: -f1)
cd $dir && uv build && uv publish --repository internal && cd -
done
Cross-Package Testing Patterns
Contract Tests
When auth-service depends on shared-models, a contract test verifies the interface:
# packages/auth-service/tests/contract/test_shared_models_contract.py
from shared_models import User
def test_user_has_required_fields():
user = User(id=1, email="test@example.com")
assert hasattr(user, "id")
assert hasattr(user, "email")
assert hasattr(user, "created_at")
If shared-models removes created_at, this test fails in the same PR, before anything ships.
Integration Test Isolation
Monorepo integration tests that need databases or external services should use Docker Compose profiles scoped per service:
# docker-compose.test.yml
services:
auth-db:
image: postgres:16
profiles: ["auth"]
billing-db:
image: postgres:16
profiles: ["billing"]
CI activates only the relevant profile: docker compose --profile auth up -d.
Migration: Multi-Repo to Monorepo
Step-by-Step
- Create the monorepo skeleton with shared config files.
- Use
git subtree addto import each repo with full history. - Move code into
packages/<name>/and update imports. - Replace PyPI dependencies on internal packages with workspace path references.
- Set up selective CI before inviting the team — nobody tolerates 45-minute builds.
Preserving Git History
git subtree add --prefix=packages/auth-service https://github.com/org/auth-service.git main brings commits into the monorepo. Developers can still run git log -- packages/auth-service to see the original history.
Tradeoffs
| Aspect | Monorepo | Multi-Repo |
|---|---|---|
| Cross-package refactors | Single atomic PR | Coordinated multi-repo PRs |
| CI complexity | High (needs selective builds) | Low (each repo independent) |
| Onboarding | Clone once, see everything | Clone only what you need |
| Repository size | Grows over time | Stays focused |
| Tooling investment | Significant upfront | Minimal |
Common Pitfalls
- Skipping workspace lockfiles — without a unified lock, packages can install incompatible versions in CI vs local.
- God packages — a
shared-utilsthat every service depends on becomes a bottleneck for every PR. Split it into focused libraries. - Ignoring CODEOWNERS — without review boundaries, a monorepo becomes a merge conflict factory.
The one thing to remember: A Python monorepo succeeds when workspace-level tooling handles dependency resolution and CI selectively rebuilds only what changed — without those, you have a big repo with big problems.
See Also
- Python Black Formatter Understand Black Formatter through a practical analogy so your Python decisions become faster and clearer.
- Python Bumpversion Release Change your software's version number in every file at once with a single command — no more find-and-replace mistakes.
- Python Changelog Automation Let your git commits write the changelog so you never forget what changed in a release.
- Python Ci Cd Python Understand CI CD Python through a practical analogy so your Python decisions become faster and clearer.
- Python Cicd Pipelines Use Python CI/CD pipelines to remove setup chaos so Python projects stay predictable for every teammate.