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

  1. Create the monorepo skeleton with shared config files.
  2. Use git subtree add to import each repo with full history.
  3. Move code into packages/<name>/ and update imports.
  4. Replace PyPI dependencies on internal packages with workspace path references.
  5. 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

AspectMonorepoMulti-Repo
Cross-package refactorsSingle atomic PRCoordinated multi-repo PRs
CI complexityHigh (needs selective builds)Low (each repo independent)
OnboardingClone once, see everythingClone only what you need
Repository sizeGrows over timeStays focused
Tooling investmentSignificant upfrontMinimal

Common Pitfalls

  • Skipping workspace lockfiles — without a unified lock, packages can install incompatible versions in CI vs local.
  • God packages — a shared-utils that 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.

pythonmonorepotooling

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.