# How to Set Up a Python uv Workspace Monorepo [2026]

> A copy-paste uv workspaces monorepo layout with one lockfile, editable local packages, CI that fails on drift, and fast installs via caching.

- Canonical: https://www.kunalganglani.com/blog/python-uv-workspace-monorepo
- Author: Kunal Ganglani
- Published: 2026-08-26 · Updated: 2026-08-26
- Category: Developer Tools · Tags: python, uv, monorepo, packaging, ci-cd

## TL;DR

A Python monorepo usually fails in boring ways: different virtualenvs, dependency drift, and CI installs that don’t match what developers ran locally. uv workspaces fix this by letting you treat the repo root as one project, generate a single lockfile, and resolve local packages together so changes show up instantly across packages. This guide gives you a copy‑paste repo layout, root and package configs, and CI steps that fail if the lockfile changes. The payoff is fast, repeatable installs and a monorepo that doesn’t slowly rot.

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:

1. Create a repo-root uv project.
1. Define workspace members for packages under `packages/*`.
1. Generate **one** root `uv.lock`.
1. Use workspace installs for instant cross-package changes.
1. Make CI run `uv sync` in a way that **fails if the lock drifts**.
1. 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.**

![turned-on laptop with computer programming codes display](https://cdn.sanity.io/images/vzekdneq/production/caf3f91129dfd54bcba3d4cc960030eddcbb0929-1200x675.webp)

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](https://docs.astral.sh/uv/)). 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](https://www.youtube.com/watch?v=AMdG7IjgSPM).

(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](/pillars/ai-engineering-production) and [AI agents](/pillars/ai-agents).)

## Creating a new project for a python uv workspace monorepo

[Watch: Python Tutorial: UV - A Faster, All-in-One Package Manager to Replace Pip and Venv](https://www.youtube.com/watch?v=AMdG7IjgSPM)

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**.

![man programming using laptop](https://cdn.sanity.io/images/vzekdneq/production/3aa943e9441c266828fd6989c25ee01685a85dd2-1200x675.webp)

Install uv using the official installer (macOS/Linux):

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Initialize a project at the repo root:

```bash
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](https://docs.astral.sh/uv/guides/projects/)). 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.

```text
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.

```text
# .python-version
3.12
```

If you care about reproducible dev environments beyond Python, this pairs nicely with my [reproducible terminal dev environment](/blog/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**.

![person holding sticky note](https://cdn.sanity.io/images/vzekdneq/production/6e222ab549622f477428dfa55ac0c3cae1641de8-1200x675.webp)

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:

```toml
[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:

1. I keep the root `project` intentionally fake (`0.0.0`). The root isn’t something you publish. It’s the orchestrator.
1. 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](/blog/gitleaks-pre-commit-ci-setup) is the boring baseline.

### Per-package `pyproject.toml` template (a real publishable package)

Example for `packages/core`:

```toml
[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:

```toml
[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](/glossary/rag), retrieval-augmented generation, and [vector embeddings](/glossary/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 write `uv.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](https://docs.astral.sh/uv/guides/projects/)). That means you can make “the lock exists” a hard invariant.

### Generate the first lockfile

From the repo root:

```bash
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:

1. Edit dependency constraints in the appropriate `pyproject.toml` (root dev deps or a package)
1. Run:
```bash
uv lock
uv sync
```

1. Run tests:
```bash
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](/blog/prompt-injection-regression-testing-ci). 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:

```bash
# 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:

```bash
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](/blog/documentation-ai-tools-use) and [AI agents](/pillars/ai-agents).

## Building distributions (and multi-package releases) from a uv workspace monorepo

You have two sane choices in a monorepo:

1. Independent versioning per package (`myrepo-core` can be `0.4.1` while `myrepo-api` is `0.9.0`).
1. Single version across all packages (everything is `2026.8.0` or 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](https://docs.astral.sh/uv/guides/projects/)). In a monorepo, I run builds package-by-package in CI so failures are scoped.

Example release steps (conceptually):

```bash
# 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](/blog/stacked-prs-github-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.toml` but forgot to commit `uv.lock`
The fix is to make “lock is authoritative” non-negotiable.

Here’s the baseline job shape:

1. Checkout
1. Install uv
1. Restore cache
1. Run `uv sync` in locked/frozen mode
1. 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.

```bash
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](/blog/ai-engineering-evals-gates) and [AI in production](/pillars/ai-engineering-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](https://www.kunalganglani.com/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](/blog/github-actions-vs-circleci).

## 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](/blog/uv-vs-pip-python).

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.

Photo by Jakub Żerdzicki on Unsplash.

## FAQ

### What is a Python monorepo and when should you use one?

A Python monorepo is a single repository that contains multiple Python packages and apps that are developed together. Use it when packages share a lot of code, need coordinated refactors, or you want one CI pipeline and one set of tooling. Avoid it if teams are fully independent and releases never need to align, because monorepos add process overhead.

### How do you prevent dependency drift in CI with a lockfile?

Make the lockfile the only allowed source of installed versions, and fail the build if it changes. In practice that means syncing from the lockfile and then running a `git diff --exit-code` check on the lock to ensure CI didn’t re-resolve dependencies. Drift stops being a debate and becomes a red build.

### How do you manage multiple packages and releases in a monorepo?

Pick a versioning strategy first: either independent versions per package or one shared version across the repo. Build and publish each package explicitly so failures don’t block unrelated releases. Use tags and CI automation to make releases repeatable, not a manual checklist someone forgets under pressure.
