In this guide
On this page ⌄
- Key takeaways
- Step 1: Find the packages
- Step 2: Identify the workspace mechanism
- Step 3: Learn how imports actually resolve
- Step 4: Map the internal dependency graph
- Step 5: Learn where the tools are configured
- Step 6: Find and enforce boundaries
- Step 7: Trace one change across packages
- A first-day checklist
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.tomlfirst. 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, printmodule.__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:
| Signal | Mechanism | How packages depend on each other |
|---|---|---|
[tool.uv.workspace] with members | uv workspace | [tool.uv.sources] acme-core = { workspace = true } |
[tool.poetry] with path dependencies | Poetry | acme-core = { path = "../core", develop = true } |
pants.toml, BUILD files | Pants | Inferred from imports, declared targets |
WORKSPACE or MODULE.bazel, BUILD.bazel | Bazel | deps on py_library targets |
[tool.hatch.envs] across packages | Hatch | Usually editable installs of siblings |
| None of the above | Ad hoc | Editable 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 nearestpyproject.toml, orruff.toml. A package-level file canextendthe root one. Runruff check --show-settings path/to/file.pyto see the resolved config for a file. - mypy: reads
[tool.mypy]ormypy.ini. In monorepos,mypy_pathandpackagesorfilessettings control what is checked and how imports are resolved. Missing stubs andignore_missing_importsoverrides are worth reading. - pyright and basedpyright: read
[tool.pyright]orpyrightconfig.json, withexecutionEnvironmentsto give each package its own root and extra paths. - pytest: finds a
rootdirfrom the nearestpytest.ini,pyproject.tomlwith[tool.pytest.ini_options],tox.iniorsetup.cfg, and loads everyconftest.pyfrom the rootdir down to the test file. Fixtures defined in a rootconftest.pyare 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
- List every
pyproject.toml,setup.pyandsetup.cfg. - Identify the workspace mechanism from the root config.
- Install with the repo's own command (
uv sync,poetry install,pants) rather than plainpip. - Note distribution names versus import names for each package.
- Check src versus flat layout and namespace packages.
- Print
__file__for any import that surprises you. - Compare declared dependencies with actual imports.
- Find where ruff, the type checker and pytest are configured.
- Read and run import-linter contracts if present.
- 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.

