How to Navigate a Python Monorepo: Packages, Imports, Boundaries

Python has no single monorepo standard, so every repo is assembled differently. Here is how to work out the packages, the import paths, the tooling and the rules.

By The OpenVisio Team · · 12 min read

Python monorepos are harder to read than their equivalents in Go or Rust because Python has no built-in notion of a multi-package repository. One repo uses uv workspaces, another uses Poetry path dependencies, a third uses Pants or Bazel, and a fourth is a single src/ tree where "packages" are just top-level directories that everybody imports from everywhere. The import system adds its own uncertainty: whether import shared.utils works depends on sys.path, on what is installed in editable mode, and on which directory you ran the command from.

This guide is a method for working out how a Python monorepo is put together: where the packages are, how they reference each other, how imports actually resolve, which tools run where, and which boundaries are enforced versus merely hoped for. The goal is to get to the point where you can confidently answer "if I change this module, what breaks" and "where should this new code go".

Key takeaways

  • Find every pyproject.toml first. Each one is usually a package, and the root one usually defines the workspace and shared tool config.
  • Identify the workspace mechanism (uv, Poetry, Pants, Bazel, Hatch, or plain editable installs). It determines how packages depend on each other and how you run things.
  • Imports resolve through sys.path. When an import surprises you, print module.__file__ instead of guessing.
  • src/ layouts prevent accidental imports from the working directory; flat layouts allow them. Know which one you are in.
  • Tool configuration (ruff, mypy or pyright, pytest) can be per package or at the root. Find out which applies before running anything.
  • Boundaries only exist if something enforces them. Look for import-linter contracts or build-system visibility rules.

Step 1: Find the packages

Start by listing every package definition:

find . -name pyproject.toml -not -path '*/.venv/*' -not -path '*/node_modules/*' | sort
find . -name setup.py -o -name setup.cfg | grep -v .venv

Older packages may still use setup.py or setup.cfg. Each file marks a distributable package with a name, dependencies and a build backend. A typical layout looks like this:

pyproject.toml            # workspace root, shared tool config
packages/
  core/pyproject.toml     # name = "acme-core"
  db/pyproject.toml       # name = "acme-db"
services/
  api/pyproject.toml      # name = "acme-api"
  worker/pyproject.toml   # name = "acme-worker"

Note the difference between the distribution name in [project] name (acme-core) and the import name (acme_core or acme.core), which is defined by the package directory. They often differ, and that difference trips up newcomers when they search.

Step 2: Identify the workspace mechanism

Read the root pyproject.toml and look for one of these:

SignalMechanismHow packages depend on each other
[tool.uv.workspace] with membersuv workspace[tool.uv.sources] acme-core = { workspace = true }
[tool.poetry] with path dependenciesPoetryacme-core = { path = "../core", develop = true }
pants.toml, BUILD filesPantsInferred from imports, declared targets
WORKSPACE or MODULE.bazel, BUILD.bazelBazeldeps on py_library targets
[tool.hatch.envs] across packagesHatchUsually editable installs of siblings
None of the aboveAd hocEditable installs, or PYTHONPATH set in scripts or CI

A uv workspace is increasingly common and is a good example of what to look for:

# pyproject.toml (root)
[tool.uv.workspace]
members = ["packages/*", "services/*"]

# services/api/pyproject.toml
[project]
name = "acme-api"
dependencies = ["acme-core", "acme-db", "fastapi>=0.115"]

[tool.uv.sources]
acme-core = { workspace = true }
acme-db = { workspace = true }

With uv, the whole workspace shares one lockfile (uv.lock) and one virtual environment at the root. Common commands:

uv sync                              # install the workspace into .venv
uv sync --package acme-api           # just one member and its deps
uv run --package acme-api pytest     # run in the context of one member
uv tree                              # dependency tree for the workspace

Pants and Bazel work differently. Pants infers dependencies from import statements and lets you query them:

pants dependencies services/api::
pants dependents packages/core::
pants peek services/api:api

In Bazel, bazel query 'deps(//services/api:api)' and bazel query 'rdeps(//..., //packages/core:core)' answer the same questions. In either case, the build files are the source of truth for dependencies, not pyproject.toml.

If you find no mechanism at all, look at CI configuration, Makefile targets, tox.ini, noxfile.py and Dockerfiles for pip install -e lines or PYTHONPATH assignments. That is how the repo actually gets assembled.

Step 3: Learn how imports actually resolve

Python resolves import acme.core by searching the entries in sys.path in order. Entries come from the script's directory (or the current directory for python -m and the REPL), PYTHONPATH, site-packages in the active environment, and .pth files that editable installs drop into site-packages.

When an import does something unexpected, do not guess. Ask Python:

python -c "import acme.core, sys; print(acme.core.__file__); print(*sys.path, sep='\n')"
python -c "import importlib.util; print(importlib.util.find_spec('acme.core'))"

The first command shows which file is actually being used and the full search path. The second shows the spec, including whether it is a namespace package with several locations.

Three layout details matter:

src layout versus flat layout. With a src/ layout (packages/core/src/acme_core/), the package is only importable once installed, because the package directory is not in the working directory. With a flat layout (packages/core/acme_core/), running from packages/core makes it importable without installation, which can hide a missing dependency declaration until production. A mix of both in one repo is common and confusing.

Namespace packages. If several packages share a top-level name like acme and none of them has acme/__init__.py, they form an implicit namespace package (PEP 420), and acme.core and acme.db can come from different distributions. If one of them accidentally adds an acme/__init__.py, the others vanish from the import path. When imports work for one package and not another, check for this first.

Relative versus absolute imports. from .models import User only works inside a package. Scripts run directly with python path/to/script.py cannot use relative imports, which is why monorepos often prefer python -m package.module.

Step 4: Map the internal dependency graph

Once you know the packages and the mechanism, build a graph of which package depends on which. Declared dependencies are in each pyproject.toml; actual dependencies are in the import statements. Compare them.

A quick way to find cross-package imports:

grep -rhoE "^(from|import) acme_[a-z_]+" services/api | sort | uniq -c | sort -rn

For a graph, pydeps can render one for a package, and pipdeptree shows installed distribution dependencies:

pipdeptree -p acme-api
pydeps services/api/src/acme_api --max-bacon 2 --cluster

Look for three problems: imports of a sibling package that is not declared as a dependency (works locally because everything is installed into one environment, fails when the package is built alone), cycles between packages, and services importing from other services instead of from shared libraries. Finding and fixing circular dependencies covers what to do about the second.

Step 5: Learn where the tools are configured

Python tools look for configuration by walking up from the file or the working directory, and each tool has its own rules. Before running anything, find out what applies:

  • ruff: reads [tool.ruff] in the nearest pyproject.toml, or ruff.toml. A package-level file can extend the root one. Run ruff check --show-settings path/to/file.py to see the resolved config for a file.
  • mypy: reads [tool.mypy] or mypy.ini. In monorepos, mypy_path and packages or files settings control what is checked and how imports are resolved. Missing stubs and ignore_missing_imports overrides are worth reading.
  • pyright and basedpyright: read [tool.pyright] or pyrightconfig.json, with executionEnvironments to give each package its own root and extra paths.
  • pytest: finds a rootdir from the nearest pytest.ini, pyproject.toml with [tool.pytest.ini_options], tox.ini or setup.cfg, and loads every conftest.py from the rootdir down to the test file. Fixtures defined in a root conftest.py are available everywhere, so a test can depend on a fixture three directories away.

Print what pytest sees for one package:

uv run --package acme-api pytest services/api --collect-only -q | tail -5
uv run --package acme-api pytest --fixtures services/api | head -40

Also check for pre-commit hooks in .pre-commit-config.yaml and the CI workflow files. CI often runs tools per package with specific arguments, and matching that locally saves you from "passes on my machine" surprises.

Step 6: Find and enforce boundaries

A monorepo's architecture is only as real as its enforcement. Look for:

import-linter. Contracts in [tool.importlinter] in pyproject.toml, or in .importlinter, that the lint-imports command checks:

[tool.importlinter]
root_packages = ["acme_api", "acme_core", "acme_db"]

[[tool.importlinter.contracts]]
name = "Core does not depend on infrastructure"
type = "forbidden"
source_modules = ["acme_core"]
forbidden_modules = ["acme_db", "acme_api"]

[[tool.importlinter.contracts]]
name = "API layers"
type = "layers"
layers = ["acme_api.routes", "acme_api.services", "acme_api.repositories"]

Run lint-imports and read the output. Each contract is a documented architectural rule, and a broken one is a known exception.

Build-system visibility. Pants and Bazel can restrict which targets may depend on which, via visibility attributes or Pants' visibility rules. These are the strongest boundaries you will find.

Conventions only. If there is no enforcement, read the README and any architecture docs, then check the import graph from Step 4 to see whether the conventions hold. They often do not.

Step 7: Trace one change across packages

Pick a function in a shared package and find every caller across the repo. A text search is a reasonable start:

grep -rn "calculate_tax" --include=*.py . | grep -v test

Then confirm by running the type checker and the test suites of every dependent package. The ability to answer "who depends on this" quickly is the main thing a monorepo should give you, and the main thing that goes wrong when boundaries are unenforced. Impact analysis before refactoring goes deeper on doing this before a change rather than after.

A first-day checklist

  1. List every pyproject.toml, setup.py and setup.cfg.
  2. Identify the workspace mechanism from the root config.
  3. Install with the repo's own command (uv sync, poetry install, pants) rather than plain pip.
  4. Note distribution names versus import names for each package.
  5. Check src versus flat layout and namespace packages.
  6. Print __file__ for any import that surprises you.
  7. Compare declared dependencies with actual imports.
  8. Find where ruff, the type checker and pytest are configured.
  9. Read and run import-linter contracts if present.
  10. Trace one shared function to every caller.

If the monorepo holds Django services, pair this with understanding a Django project. Codebase Knowledge by OpenVisio can index a Python monorepo in your browser and show the package-level dependency graph in Explore, which is a fast way to compare the architecture you were told about with the one the imports actually describe.

Pythonmonorepouvimport-linter

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading