After working with a couple of larger Python projects, I noticed that I was no longer struggling with Python itself. I was struggling with the relationships between projects.
Most examples for uv start with one application or one library. That is a logical place to begin. My daily work, however, often involves several repositories. An application depends on shared business functionality, which in turn depends on an internal framework. A feature can start in the application and end up requiring changes in all three repositories.
My first solution was to build a new wheel every time I changed the framework or the shared core. It worked, but after doing that several times in one afternoon, I started wondering whether I was using the right workflow. I wanted the reliability of released packages without having to publish an experimental version after every small code change.
That is where uv became interesting to me. Not because I wanted another package manager, but because it gave me several ways to move between released packages and local source code without changing the architecture of the projects.
This post is about that workflow. I am not trying to define the one correct Python project structure. I want to explain what I prefer, where it helps me, and which trade-offs I have learned to look for.
The Moment a Python Project Grows Up#
For this article, I use three generic project names:
platform-frameworkcontains reusable technical building blocks. It should not know anything about a specific business domain or application.platform-coreadds shared domain and platform capabilities on top of the framework.sales-applicationis one of the applications that uses those capabilities to solve a concrete business problem.
Their dependency direction is simple:
platform-framework
^
|
platform-core
^
|
sales-applicationThese names are intentionally generic. The important part is the relationship between them. The application can depend on the shared core, and the core can depend on the framework. The framework must never reach back into either of its consumers.
I keep these as separate repositories because they have different lifecycles. The framework is an internal product with multiple consumers. The core is shared across applications. The application is independently deployed and changes much more frequently.
Inside each repository, there can still be multiple Python packages. For example, the framework might contain separate packages for its core API, infrastructure integration, and evaluation support. The application might contain a backend, gateway, and reporting service. A repository boundary and a Python distribution boundary do not have to be the same thing.
My Normal Workflow Starts with Released Packages#
Most days I work only in sales-application. In that situation, I do not want to think about source overrides or the internal structure of the other repositories. I want to synchronise the environment, start coding, and trust that I am using released versions of the framework and core.
cd sales-application
uv sync
uv run pytestThe application declares the portable dependency in its standard project metadata:
[project]
name = "sales-application"
dependencies = [
"platform-core>=1.4,<2",
]The released platform-core wheel comes from a private package index. I like this as the default because it makes the version I am running explicit and gives CI the same artifact that I use locally.
[[tool.uv.index]]
name = "company"
url = "https://packages.example.com/simple/"
explicit = true
[tool.uv.sources]
platform-core = { index = "company" }I use an explicit index for our internal packages. This binds platform-core to that index instead of allowing it to be resolved from another configured index. Credentials do not belong in pyproject.toml; they should come from local credential configuration or the CI secret store.
This is the boring workflow, and I mean that as a compliment. Released packages, a committed lock file, and a project environment should make ordinary application development predictable.
When a Feature Crosses a Repository Boundary#
The interesting part starts when I discover that a change does not belong in the application.
Imagine that I am implementing a sales feature and need additional behaviour from platform-core. I can make the core change, build a wheel, publish it, update the application, and try again. If I find another problem, I repeat the complete cycle.
For a finished change, that release process is exactly what I want. During development, it gets in my way.
uv separates the dependency from the source used to obtain it. The dependency remains in [project.dependencies], while [tool.uv.sources] can tell uv to use a different source during development. The application can temporarily use a neighbouring checkout:
[project]
dependencies = [
"platform-core>=1.4,<2",
]
[tool.uv.sources]
platform-core = { path = "../platform-core", editable = true }The version constraint still describes the dependency contract. The source entry tells uv where to obtain that dependency in this checkout. The source mapping is uv-specific and is not part of the package metadata published with the application.
With an editable path source, changes in platform-core are immediately visible to the application. You can run an application test, switch to the core repository, change the implementation, and run the application test again without building an intermediate wheel.
I treat this path mapping as a temporary local override. A relative path depends on how the repositories are organised on a machine. CI may not have the second repository, and a Docker build usually cannot access a directory outside its build context. I therefore do not want this to become the permanent dependency model.
If another developer or CI needs to test the same unreleased change, an exact Git commit can be a useful intermediate source:
[tool.uv.sources]
platform-core = {
git = "ssh://git@example.com/platform/platform-core.git",
rev = "58a914..."
}I prefer a commit over a moving branch. A branch called main may resolve to different code tomorrow. An exact commit tells me precisely which source was tested. Even then, Git remains an integration tool for me, not the normal way to distribute released internal packages.
Sometimes the Change Goes All the Way to the Framework#
Every now and then, the change in platform-core exposes a missing capability in platform-framework. At that point, I have three repositories open:
development/
|-- platform-framework/
|-- platform-core/
`-- sales-application/The same source-override pattern works one level lower. platform-core temporarily uses the local framework checkout, while sales-application temporarily uses the local core checkout.
# platform-core/pyproject.toml
[project]
dependencies = [
"platform-framework>=2.3,<3",
]
[tool.uv.sources]
platform-framework = {
path = "../platform-framework",
editable = true
}This gives me a fast development loop through all three layers. Framework changes can be validated in the application before anything is published.
There is an important distinction here. I am not turning three independent repositories into one permanent workspace. Each repository still owns its dependency graph, lock file, release cycle, and virtual environment. I am temporarily connecting local source trees because the feature crosses those boundaries.
That is also why I want to test the final wheel before I consider the work done. Source-based development proves that the code works together. It does not prove that the wheel contains the correct files and metadata.
Where Workspaces Fit into My Workflow#
I deliberately did not start this article with workspaces. For me, a workspace becomes relevant only after I look inside one of the repositories.
Suppose platform-framework contains several packages that are maintained by the same team and often changed together:
platform-framework/
|-- pyproject.toml
|-- uv.lock
`-- packages/
|-- framework-core/
| `-- pyproject.toml
|-- framework-infra/
| `-- pyproject.toml
`-- framework-evaluation/
`-- pyproject.tomlThis is where a uv workspace is useful. Each member remains a Python project with its own pyproject.toml, but the repository resolves them together and uses one lock file.
[tool.uv.workspace]
members = ["packages/*"]When one framework package depends on another, the logical dependency stays in [project.dependencies], while a workspace source is used during development:
[project]
name = "framework-evaluation"
dependencies = [
"framework-core",
]
[tool.uv.sources]
framework-core = { workspace = true }Workspace members are editable, so changes are available without rebuilding or reinstalling the member after every edit. That makes cross-package refactoring pleasant while still allowing each package to become its own wheel.
The same reasoning can apply independently to platform-core and sales-application. I use a workspace when packages live in the same repository, are maintained together, and benefit from one dependency resolution. I do not use a workspace only because I want to save disk space or share a virtual environment.
For me, a workspace is primarily a development and dependency-resolution boundary. It is not automatically a release or deployment boundary. The backend and gateway can share a workspace and lock file while still becoming separate container images.
One Workspace, One Lock File, One Environment#
A shared uv.lock is one consequence of choosing a workspace. uv resolves all workspace members as one graph. That catches conflicting requirements early, but it also means a dependency update can affect several packages at once.
I think about a lock file as a boundary: which components should always resolve and upgrade their dependencies together? If the answer is all packages in one repository, one workspace lock file is a good fit. If services need different Python versions or independent dependency lifecycles, they probably should not be in the same workspace.
The lock file for a reusable library has a different role from the lock file for an application. The framework lock file makes framework development and CI reproducible. A consumer does not install that lock file. It resolves the framework’s declared dependency ranges into its own lock file.
The application lock file is the one that pins the complete deployed environment. That is why I commit lock files and make CI reject an outdated one:
uv lock --check
uv sync --locked
uv run pytestLocking and syncing are related but different. Locking resolves the graph into uv.lock; syncing installs the required part of that graph into the environment. uv normally handles both automatically, while --locked is useful in CI because it fails instead of silently changing the lock file.
The virtual environment follows the same boundary. One independent uv project gets one .venv; one workspace gets one workspace-level .venv.
I avoid sharing a manually managed virtual environment between independent repositories. Installing a new package for sales-application could otherwise alter the environment used by platform-core, even though the two repositories have different lock files. It also makes undeclared dependencies difficult to spot because an import may work locally only because another project installed it.
The uv cache already avoids much of the repeated download and build cost. There is no need to trade away isolation by sharing one mutable environment across unrelated projects.
Moving Back from Source to a Released Wheel#
Local source overrides are useful while the feature is moving. They are not where I want the workflow to end.
Once the framework change is ready, I build and test its distribution:
cd platform-framework
uv run pytest
uv build --wheelA wheel is the artifact I want consumers to install. It contains the built distribution and its metadata, and it can be installed without rebuilding the package from source. I publish that wheel to the private package index as a versioned release.
Next, I remove the local framework override from platform-core, update its dependency to the released framework version, lock, synchronise, and run the tests again. Then I build and publish the platform-core wheel and repeat that final validation in sales-application.
That last step is important. During implementation I developed against source because it gave me a fast feedback loop. Before merging and deploying, I test against the artifacts that CI and production will actually install.
My steady-state distribution model is:
Source collaboration private Git repositories
Released Python packages private package index
Deployable applications container registryGit is where I collaborate on source. The package index is where I distribute immutable wheels. The container registry is where I distribute deployable applications. Keeping those responsibilities separate makes releases easier to trace and roll back.
Choosing a Dependency Source#
I do not see path, Git, wheel, index, and workspace sources as competing solutions. They solve different moments in the workflow.
| Source | When I use it |
|---|---|
| Workspace | Packages in the same repository that I develop and resolve together |
| Local editable path | Short-lived development across neighbouring repository checkouts |
| Exact Git commit | Sharing or testing an unreleased change when a local path is not available |
| Wheel path | Testing the exact artifact or working in an offline delivery flow |
| Private index | Normal development, CI, releases, and production |
My preference is simple: published packages are the default; local source overrides are the exception. That gives me reproducible builds most of the time and a fast development loop when a change crosses repositories.
There is also a useful verification option for checking whether a package still works without uv’s development source mappings:
uv lock --no-sourcesBecause [tool.uv.sources] is specific to uv, this check resolves from the portable package metadata instead of the workspace, Git, URL, or local path source. It helps reveal when the released dependency metadata no longer describes what the project actually needs.
What I Am Taking Away from This#
The most important choice is not whether uv technically supports a particular layout. It usually does. The more useful question is which lifecycle boundary that choice creates.
These are the preferences I am taking into my projects:
- I use a workspace for packages in the same repository that should be changed and resolved together.
- I keep independent repositories on separate lock files and virtual environments.
- I declare the portable dependency in
[project.dependencies]and use[tool.uv.sources]to change where uv obtains it during development. - I use local paths or exact Git commits for coordinated development, not moving branches as permanent production dependencies.
- I consume released internal packages from an explicit private index.
- I build and test the wheel before calling a library release complete.
- I treat a workspace as a development boundary, not automatically as a deployment boundary.
This setup gives me two workflows instead of forcing one approach everywhere. Most days I consume released packages and do not think about any of this. When a feature crosses into platform-core or platform-framework, I temporarily work against source and keep the feedback loop short. Once the change is ready, I move back to versioned wheels and a locked application environment.
The practical follow-up is Making Larger Python Projects Work with uv: From Source to Docker. It turns this architecture into a working example with platform-framework, platform-core, and sales-application, including package boundaries, development profiles, lock files, a package index, and reproducible container builds.

