Table of Contents

Monorepos look incredibly attractive on paper—one repository, shared tooling, consistent standards across every microservice. Then you try to build one with Python’s Poetry, and reality hits hard.

A Poetry monorepo can be an elegant, rock-solid foundation for enterprise engineering, but only if you intimately understand its rules. Poetry is notoriously unforgiving. It rewards strict discipline and harshly punishes shortcuts. Unlike npm, Yarn, or Cargo, Poetry does not have a native “workspace” feature out of the box. This single design choice defines everything about how you must structure your repository.

In this deep dive, we are going to explore what actually works, what commonly fails, and how to architect a Poetry monorepo in production without fighting the tool.

The Missing Feature: Native Workspaces

If you come from the JavaScript ecosystem, you are likely used to declaring a workspace root and letting the package manager hoist shared dependencies to the top level. Poetry does not do this. Poetry treats every pyproject.toml as an isolated, sovereign state.

Poetry handles dependency resolution, locking, and packaging perfectly, but because it lacks native orchestration, there is no shared root poetry.lock in a healthy Poetry monorepo.

Attempting to force a single, gigantic poetry.lock file at the root of your repository is the number one reason teams abandon Poetry monorepos. It leads to insurmountable dependency conflicts when two internal microservices require different versions of a third-party library like pydantic or requests.

The Golden Monorepo Architecture

Because each package must be independent, the safest and most scalable structure isolates business logic from deployment targets (like APIs or workers).

my-company-monorepo/
├── packages/
│   ├── core/              # Shared models, DB connections
│   │   ├── pyproject.toml
│   │   └── poetry.lock
│   ├── api-gateway/       # FastAPI application
│   │   ├── pyproject.toml
│   │   └── poetry.lock
│   └── background-worker/ # Celery tasks
│       ├── pyproject.toml
│       └── poetry.lock
├── Makefile
└── .github/workflows/

In this structure, api-gateway and background-worker are consumers. They both rely on the core package.

Linking Packages: Path Dependencies

How do we get the API Gateway to talk to the Core package without publishing Core to a private PyPI server? We use Path Dependencies.

Notice the develop = true flag. This is critical. It installs the internal dependency in editable mode, meaning changes you make to the core package instantly reflect in your API during local development without needing to reinstall.

[tool.poetry]
name = "api-gateway"
version = "0.1.0"
description = "Public facing REST API"

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.103.0"
core = { path = "../core", develop = true }

The core package acts as a standard library. It does not know about the API or the worker.

[tool.poetry]
name = "core"
version = "0.1.0"
description = "Shared database models and utilities"

[tool.poetry.dependencies]
python = "^3.11"
sqlalchemy = "^2.0.0"
pydantic = "^2.3.0"

Dependency Groups and CI/CD Speeds

One of the hidden traps in a Poetry monorepo is CI/CD execution time. If you have 10 microservices, running poetry install and pytest 10 times in a GitHub Actions runner will easily consume 15 minutes.

To optimize this, you must standardize your Dependency Groups across all pyproject.toml files. If every package uses the exact same version of pytest, ruff, and mypy, Docker can cache the layers infinitely better.

[tool.poetry.group.dev.dependencies]
pytest = "7.4.0"
ruff = "0.0.285"
mypy = "1.5.1"
By strictly pinning dev dependencies to exact versions across all packages, you prevent Poetry from re-resolving different minor versions of testing frameworks in your CI pipeline.

Orchestrating the Monorepo

Because Poetry lacks orchestration, you need an external tool to run commands across all packages. While enterprise teams might reach for Bazel or Pants, a simple Makefile is usually enough for 90 percent of teams.

PACKAGES := packages/core packages/api-gateway packages/background-worker

.PHONY: install test lint

install:
	for pkg in $(PACKAGES); do \
		echo "Installing $$pkg..."; \
		(cd $$pkg && poetry install) || exit 1; \
	done

test:
	for pkg in $(PACKAGES); do \
		echo "Testing $$pkg..."; \
		(cd $$pkg && poetry run pytest) || exit 1; \
	done

This allows a developer to run make test from the root, and the script systematically traverses the monorepo, executing isolated environments perfectly.

Common Traps and Fatal Mistakes

If you are migrating to this structure, avoid these common pitfalls:

Mixing pip and Poetry

Never run pip install inside a Poetry-managed virtual environment. It bypasses the lockfile, creating severe dependency drift between your local machine and your production environment.

Python Version Misalignment

If your core package specifies python = "^3.11" and your api package specifies python = "^3.10", Poetry will refuse to resolve the path dependency. Python version requirements must be identical or strictly overlapping across the monorepo.

Manual Lockfile Edits

Never manually edit a poetry.lock file to resolve a Git merge conflict. Always accept the incoming changes to pyproject.toml and run poetry lock --no-update to regenerate a mathematically sound lockfile.

Final Thoughts

A Poetry monorepo is not forgiving, but it is deeply predictable. If you respect Poetry’s model-independent packages, explicit path dependencies, separate lock files, and consistent Python versions-you get unparalleled stability. If you fight it by trying to force single lockfiles or messy global environments, the monorepo will fight back harder.

Related Reading: To see how these architectures deploy into production, check out my thoughts on Using uv with Docker Multi-Stage Builds and how to handle infrastructure in Managing State: Redis vs Cosmos DB.

Official Documentation: For more deep-dive technical details, refer to the official Python documentation.