Originally published at kunalganglani.com — read it there for inline code, hero image, and live links.
I’ve watched more “Python monorepos” die from boring plumbing than from any real architectural problem. It’s always the same mess: three virtualenvs per developer, a lockfile nobody trusts, and CI quietly resolving something different than what you ran locally.
A python uv workspace monorepo is how you stop that rot. One lockfile. One .venv. Local packages that behave like first-class dependencies instead of path-hack science experiments. And CI installs that don’t “helpfully” re-resolve anything behind your back.
The prerequisite that trips people up is simple: treat the repo root as the project. That’s where uv.lock and .venv live. Your packages are workspace members, not separate snowflake environments.
This post is updated for current uv and matches Astral’s 2026-era recommended layout (root .venv + uv.lock). The docs are solid, but the monorepo recipe is scattered across pages and examples. Here’s the stitched-together version you can paste into a repo and ship.
Here’s the exact flow we’re building:
- Create a repo-root uv project.
- Define workspace members for packages under
packages/*. - Generate one root
uv.lock. - Use workspace installs for instant cross-package changes.
- Make CI run
uv syncin a way that fails if the lock drifts. - Cache uv downloads so CI isn’t paying the cold-start tax every run.
What is uv in Python and how is it different from pip/Poetry?
uv is an extremely fast Python package and project manager, written in Rust, that manages Python versions, virtual environments, dependencies, lockfiles, and builds in one tool.
Astral positions it as “one tool to replace pip, pip-tools, pipx, Poetry, pyenv, twine, and virtualenv and more, and claims it can be 10–100× faster than pip thanks to aggressive caching and a modern resolver (Astral uv documentation). It also supports Cargo-style workspaces, which is the part that finally makes Python monorepos less miserable.
My take is opinionated: uv is the first packaging tool in years that feels like it was designed for people who maintain real codebases, not tutorials. I’m not interested in another “activate this venv, but not that venv” ritual. I want repeatable installs and fast CI, and I want the workflow to be obvious enough that nobody on the team has to memorize tribal lore.
If you want a refresher on uv basics before we go workspace-heavy, this walkthrough is solid: Corey Schafer.
(And if your monorepo includes agent tooling, you’ll end up caring about supply chain and lock discipline anyway. I’ve written more about that in AI in production and AI agents.)
Creating a new project for a python uv workspace monorepo
[YOUTUBE:AMdG7IjgSPM|Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv]
Start with a clean repo that has a root pyproject.toml. Yes, even if you’re thinking, “the root isn’t a real package.” That’s the point. Treat the root as the workspace controller.
Install uv using the official installer (macOS/Linux):
curl -LsSf https://astral.sh/uv/install.sh | sh
Initialize a project at the repo root:
mkdir myrepo
cd myrepo
uv init
Astral’s project guide is explicit about the behavior you want: uv will create a virtual environment and uv.lock in the project root the first time you run a project command like uv run, uv sync, or uv lock (Astral project guide). That’s the core monorepo trick. One root environment, one root lock. Anything else is a slow-motion argument waiting to happen.
Now create a workspace layout. I like this tree because it makes the repo readable. Libraries under packages/. Runnable things under apps/. Random scripts in tools/ where they belong.
myrepo/
pyproject.toml
uv.lock
.python-version
.venv/
packages/
core/
pyproject.toml
src/core/
__init__.py
api/
pyproject.toml
src/api/
__init__.py
apps/
worker/
pyproject.toml
src/worker/
__init__.py
tools/
scripts/
Numbers matter in monorepos because scale is what breaks your workflow. This layout still feels sane at 3 packages, and it stays sane at 30 because it doesn’t try to be clever.
While you’re here, pin a Python version. uv supports .python-version directly (same file format lots of teams already use). Set something current like 3.12.
# .python-version
3.12
If you care about reproducible dev environments beyond Python, this pairs nicely with my reproducible terminal dev environment setup.
Project structure: what lives at root vs each package
This is where most “we can fix it later” monorepos go to die. Later never comes. You need a clean line between repo-level orchestration and package-level reality.
At repo root, you want:
-
pyproject.toml: workspace definition + shared dev tooling deps -
uv.lock: the universal lockfile (single source of truth) -
.venv/: the one virtualenv developers and CI use -
.python-version: your Python pin
Inside each package (for example packages/core/pyproject.toml), you want:
- Package metadata (
name,version, build backend) - Package-specific dependencies
- Optional extras (for example
dev,test), if you’re versioning them independently
Root pyproject.toml template (workspace controller)
This is a minimal root config that makes the repo behave like a monorepo instead of a folder full of unrelated Python projects:
[project]
name = "myrepo"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = []
[tool.uv]
# Workspace members (Cargo-style). Keep it boring.
workspace = { members = ["packages/*", "apps/*"] }
[tool.uv.dependencies]
# Optional: if you want shared tooling dependencies at the root env.
# I usually keep this to dev tools only.
[tool.uv.dev-dependencies]
pytest = "^8.0.0"
ruff = "^0.5.0"
Two opinions I’ll defend:
- I keep the root
projectintentionally fake (0.0.0). The root isn’t something you publish. It’s the orchestrator. - Put dev tooling in one place. If each package picks its own lint/test stack, you’ll spend your time chasing version mismatches instead of shipping.
If you’re doing serious CI hygiene, pair this with a secrets posture. Monorepos are credential leak magnets. My CI/CD hardening flow is the boring baseline.
Per-package pyproject.toml template (a real publishable package)
Example for packages/core:
[project]
name = "myrepo-core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.7.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Example for packages/api that depends on core locally:
[project]
name = "myrepo-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"myrepo-core",
"fastapi>=0.115.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Notice what I’m doing: api depends on myrepo-core by name, not by path.
That’s not pedantry. It’s how you keep a dependency graph that makes sense when you publish, while still getting local workspace resolution during development.
If your repo is for agent work, this same pattern is how you stop “eval harness,” “RAG utils,” and “prod service” from turning into a circular import bonfire. Related: RAG, retrieval-augmented generation, and vector embeddings.
Managing dependencies and uv.lock reproducible installs
Lock discipline is where Python monorepos either become boring (good) or become folklore (bad).
uv’s model is simple:
-
uv lock: resolve and writeuv.lock -
uv sync: install exactly what the project needs into.venv -
uv run: run a command inside the managed environment
From Astral’s guide, the key behavior is timing: the first time you run uv run, uv sync, or uv lock, uv creates .venv and uv.lock at the project root (Astral project guide). That means you can make “the lock exists” a hard invariant.
Generate the first lockfile
From the repo root:
uv lock
Now you should see uv.lock. Depending on what you’ve run already, you may also see .venv/.
Update dependencies safely (the only workflow I trust)
Lock updates are where teams get sloppy because “it’s just dev tooling” or “it’s a tiny bump.” That’s how you earn flaky builds.
My workflow is boring on purpose:
- Edit dependency constraints in the appropriate
pyproject.toml(root dev deps or a package) - Run:
uv lock
uv sync
- Run tests:
uv run -m pytest
That last command is a small thing, but it’s a monorepo superpower. One place to run tests. One environment. No “cd into package and hope your venv is right.”
This is also where lockfiles stop being “convenience” and start being supply-chain control. If you’re doing anything with production AI, your dependency graph is an attack surface. I treat lock drift the same way I treat prompt injection. You don’t rely on memory. You build a gate.
Running commands + editable installs in a workspace (without the footguns)
Editable installs in Python have a long history of being fragile. Path tricks, import weirdness, tools behaving differently inside vs outside editable mode. Everyone has a scar here.
Workspaces are the clean answer. Your packages are resolved as workspace members, so changes in packages/core are immediately visible to packages/api when you run commands from the root.
The day-to-day commands I actually use look like this:
# Run tests for the whole repo
uv run -m pytest
# Run a module from a package (example)
uv run -m api
# Start an app entrypoint (example)
uv run worker
If you’re using ruff, keep it root-scoped too:
uv run ruff check .
And if you’re building agent tooling, you’ll usually end up with multiple “apps” sharing a core library. Workspaces keep that tight without playing whack-a-mole with venvs. It’s the same reason I like clean boundaries in agent orchestration setups.
Internal docs matter here. If you want the repo to be friendly to new joiners and to AI agents that operate on codebases, read: AI-Readable Documentation and AI agents.
Building distributions (and multi-package releases) from a uv workspace monorepo
You have two sane choices in a monorepo:
- Independent versioning per package (
myrepo-corecan be0.4.1whilemyrepo-apiis0.9.0). - Single version across all packages (everything is
2026.8.0or similar).
I strongly prefer independent versioning unless you’re shipping a tightly coupled suite. Single-version monorepos look tidy until you need to ship an urgent patch in one leaf package and now everything gets a version bump. That’s not “consistency.” That’s busywork.
To build distributions, uv supports building projects directly (see the “Building distributions” section in the official guide: Astral project guide). In a monorepo, I run builds package-by-package in CI so failures are scoped.
Example release steps (conceptually):
# Build core
cd packages/core
uv build
# Build api
cd ../api
uv build
Publishing depends on your index, auth, and policy. There isn’t a one-liner that fits everyone, and anyone telling you there is probably hasn’t dealt with real release constraints.
What matters is the shape:
- Tag the repo (or tag each package, if you want)
- Build each package dist
- Publish each package dist
If you’re already doing controlled releases elsewhere (for example Go services), steal that discipline. I apply the same “release is a pipeline, not a ceremony” mindset as in my stacked PRs workflow.
CI: uv sync frozen CI so installs don’t drift
CI drift usually comes from one of these:
- CI resolving deps without using the lock
- CI allowing the lockfile to be regenerated implicitly
- A developer updated
pyproject.tomlbut forgot to commituv.lock
The fix is to make “lock is authoritative” non-negotiable.
Here’s the baseline job shape:
- Checkout
- Install uv
- Restore cache
- Run
uv syncin locked/frozen mode - Run tests
Even if you don’t remember exact flag names by heart, you can still enforce drift resistance.
Make the build fail if uv.lock changes after install. Period.
uv sync
git diff --exit-code uv.lock
That turns “whoops, CI updated the lock” into a red build that somebody has to fix properly.
I’ve built enough internal tooling to distrust anything that isn’t enforced by the pipeline. The same philosophy shows up in how I think about agent regressions. If you’re building agents, you already know why. See: AI engineering evals and AI in production.
Cache uv downloads in GitHub Actions (so you get the speed claims in CI)
Astral calls out a global cache for dependency deduplication as a core feature (Astral uv documentation). That cache is the difference between “uv is fast” and “uv is a nice idea.”
In CI, cache:
- uv’s global cache directory
- optionally
.venv/(I usually don’t. It’s brittle across runner images and a great way to debug ghosts.)
Your cache key should include:
- OS
- Python version (for example 3.12)
- a hash of
uv.lock
That gives you deterministic invalidation. When the lock changes, the cache rotates. When it doesn’t, installs are warm.
If you’re also running LLM tooling in CI (yes, people do this now), caching becomes even more important. From the LLM pricing tracker I maintain at /llm-prices, build pipelines have a habit of quietly turning into cost centers when they start calling APIs. Keeping your Python plumbing fast and stable is one of the few easy wins left.
For CI platform comparisons, I’ve also written: GitHub Actions vs CircleCI 2026.
uv vs Poetry/PDM/pip-tools for this monorepo workflow
For a monorepo, the bar is higher than “can install dependencies.” You need:
- One lock strategy that covers multiple packages
- Fast installs in CI with caching
- Workspace-local resolution so cross-package changes are instant
- A build story that doesn’t involve four tools duct-taped together
Poetry can do parts of this, but I’ve watched teams get stuck in plugin land or in “Poetry lock is different in CI” weirdness. pip-tools is solid, but it isn’t a monorepo-native workflow. PDM is closer, but uv’s performance and single-tool scope matter.
Astral’s stated goal is blunt: uv is meant as a single tool replacing pip, pip-tools, pipx, Poetry, pyenv, twine, and virtualenv (Astral uv documentation). That consolidation is what makes workspaces feel coherent.
If you want the broader comparison, I’ve already gone deeper here: uv vs pip in 2026.
My prediction: by 2027, “Python monorepo” stops being synonymous with “custom Makefile and vibes.” uv workspaces are the first credible path to a default monorepo workflow that doesn’t rot. If you adopt it, make lock discipline non-negotiable from day one. Your future CI bill and your future on-call self don’t need the extra drama.
Originally published on kunalganglani.com
![How to Set Up a Python uv Workspace Monorepo [2026]](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff05rx5vovmocomm925h1.png)












