Rippling's Migration to UV from Poetry: Python Dependency Management at Scale

In this article
Scaling Rippling's engineering team and our 24M-line monolith demanded a faster, more reliable Python dependency manager. When lock times soared past 10 minutes, Nick Grisafi, Senior Software Engineer on Rippling's DevX team, made the move to accelerate.
In just four weeks, Nick and his team successfully migrated the entire Python monolith from poetry to uv and got lock and install times down to under a minute. Read about their migration strategy and initial results below.
Introduction
Rippling has hundreds of engineers contributing to a gigantic Python monolith with 24 million lines of code and tons of third party dependencies. In 2021, we migrated from pip + requirements.txt to poetry. Using pip to manage dependencies was extremely painful for both product and infrastructure teams - without a dependency resolver, adding a new package meant finding the right version range by hand. Without a lockfile we could not guarantee reproducible builds and we had no visibility in the dependency versions we were installing. On top of that, it was slow - taking 450 seconds or more to install all 40 dependencies (roughly 300 including transitive).
Poetry was a major upgrade. Install times dropped to 150 seconds, we finally had a lockfile with reproducible builds, and developers got an intuitive CLI. But Rippling was growing fast. By the start of 2025 - 4 years later - we had 386 dependencies in our pyproject.toml, 628 in our poetry.lock, and the lockfile had ballooned to a gargantuan 16,454 lines. The dependency workflow had become so slow it was unusable and was a conflict-prone tax on everyday development.
In this post, we'll cover how we once again leveled up our Python dependency management - this time by adopting uv from Astral. We'll walk through the problems that pushed us to migrate, the incremental strategy we used to swap out poetry without disrupting hundreds of engineers, and the results we saw on the other side.
The Problems
Slow dependency operations
With poetry's adoption we had to maintain a lockfile and wait 350 seconds to update a dependency. Poetry version upgrades brought short-lived improvements, but we found ourselves constantly battling the lockfile. By 2025, a lockfile refresh could take 10+ minutes and installs could take ~5 minutes.
Frequent lockfile merge conflicts
Around 21 pull requests could change the lockfile in a given week. poetry.lock merge conflicts had become frequent enough that changes piled up faster than a single pull request could make it through the merge queue. This isn't unique to us - "make the lockfile more merge-friendly" has been a long-running request from the poetry community.
A compounding feedback loop
The combination of slow operations and frequent conflicts created a nasty feedback loop. By the time CI finished, the lockfile had often changed again, so engineers would fix conflicts, rerun poetry lock, and wait all over again. Engineers started treating dependency updates like mini-migrations.
Why uv?
Around the time we hit our lockfile inflection point, uv (a newer Python project management tool built in Rust by Astral) was getting attention for dramatically faster resolves/installs and a lockfile designed to reduce merge conflicts (our two big pain points).
Rippling has been partnered with Astral on getting tools that work at our scale since 2023. We made a successful leap to Astral’s ruff and saw massive performance improvements in lint/format. Astral also released an import graph tool that put our test selection in overdrive, reducing graph generation times from 50 minutes to 1 minute or less. We always worked closely with Astral when adopting their tools, tested on our codebase and provided feedback and bug reports - they were always quick to respond and ready to support our use case. This made us confident in betting on their tooling again.
The Solution
We migrated from poetry to uv using a dual-run strategy. In Python packaging, a lockfile is the concrete, fully-resolved dependency graph (including transitive dependencies) that tooling uses to produce repeatable installs. We rely on it to keep dev machines, CI, and production in sync and to make dependency changes explicit and reviewable. At Rippling’s scale, even one accidental package upgrade during the migration would be an unacceptable risk. So to reduce risk, we ran poetry and uv together, proved parity, and then flipped the default. During the transition we generated both poetry.lock and uv.lock and resolved diffs (missing packages, version drift, and resolver differences). Alongside the dual-run, we introduced guardrails (linting + standardized CLI commands) to keep the repo compatible and reduce churn.
The outcome was immediate once we completed the cutover: lock and install each dropped to ~1 minute (down from ~10 minutes to lock and ~5 minutes to install at the start of the project).

How We Did It
Our migration plan was intentionally incremental. Rippling has hundreds of engineers that rely on existing tools and our goal was to keep their developer loop working and cause no impact in production.

We knew the biggest risk for production was dependency resolution drift between tools. Meaning we can change what we ship in production without realizing it. So we built explicit parity checks to compare poetry.lock to uv.lock.
Milestone 1: Prepare
Before we could leave poetry, we had to upgrade to a newer version. We were pinned to poetry 1.8.2 in the monolith (released March 2024).
Older versions of poetry typically put package metadata (name, version, dependencies) under the poetry-specific [tool.poetry] section in pyproject.toml. The broader Python ecosystem has standardized on PEP 621, which defines a tool-agnostic [project] table for that same metadata.
Jumping straight from poetry 1.8.2 to uv would have meant changing the package manager tool and migrating our metadata conventions at the same time. That’s too many moving pieces to guarantee no disruption.
Instead, we upgraded poetry in two steps (1.8.2 → 1.8.5 → 2.1.1). Version 1.8.5 was the final release before poetry v2, and poetry v2 supports PEP 621. With poetry v2 we could standardize our pyproject.toml in a way that kept poetry working while we introduced uv in parallel. To prepare for the upgrade, we scrubbed poetry-specific dependency syntax and added a lint to prevent incompatible patterns from creeping back in. The poetry version used to generate poetry.lock was enforced to avoid compatibility issues and unnecessary churn.
Our poetry lint focused on three checks.
Disallowing poetry caret requirements, which can imply broad version ranges and aren’t valid PEP 508 (the standard for dependency specifiers and environment markers).
Disallowing “source” annotations and requiring the default index. In our repo, our internal index was already the default for all packages, so source annotations were unnecessary but if you do need them, uv supports sources per dependency.
Verifying the lockfile was generated with the expected poetry version.
For example, pyproject.toml changes:

To enforce the poetry version, we used the metadata at the top of the lockfile itself.
This version was extracted and compared to the expected version. Worth noting, uv supports this as a first-class feature via required-version, so you get version enforcement out of the box.
The major version bump came with its gotchas. The biggest change was the removal of the poetry shell command (release notes). The recommendation is the poetry shell plugin or using the activation script (source .venv/bin/activate) — which is also what poetry env activate prints. The activation script is the way to go because it’s tool-agnostic (it works for both poetry and uv).
There were a few command changes too. poetry install –-sync was deprecated in favor of poetry sync. poetry lock became poetry lock -–regenrate. And poetry lock –-no-update became the default behavior for poetry lock.
Conveniently, these shifts made poetry v2’s line up with the default behavior of uv.
Our developer CLI (lde - local development environment) commands were updated to smooth over these differences. For backwards compatibility, we detected the installed poetry version and chose the correct interface for invocation.
To combat future API changes like this, we introduced a new venv command group to lde and deprecated poetry-flavored commands so engineers had one stable interface while we swapped the engine underneath.

Some example commands:
1
2
3
4
lde venv add requests --version 2.32.3
lde venv lock
lde venv update
lde venv shellAt this point we were in the perfect position to bump the major poetry version. We had control over the version the developers used, frequently used commands will not break and we had a lint check to safeguard CI. So we bumped the version and regenerated the lockfile without a hitch.
Once we were on poetry v2, moving to PEP 621 was straightforward. This milestone delivered incremental value as developers got a clearer, more consistent interface for Python dependencies and virtual environments, plus a more modern pyproject.toml that other tooling could understand.

Again, we landed this metadata update without disrupting developer loops.
Milestone 2: Dual Run
After pyproject.toml metadata was standardized to PEP 621, we reduced tool-specific surface area and made dependency declarations easier to reason about. It also enabled the ability to run both poetry and uv without maintaining two competing configuration formats.
Next, we introduced uv configuration to pyproject.toml.

All lde commands were wired up to support both uv and poetry. The CLI determined which tool(s) to run based on the presence of poetry.lock and uv.lock.

For power users, we documented uv equivalents for common operations to reduce confusion during the transition. Concretely, the translations looked like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
# Install
poetry install --sync # Poetry v1
poetry sync # Poetry v2
uv sync --frozen # uv
# Lock
poetry lock --no-update # Poetry v1 (default behavior in v2)
poetry lock # Poetry v2
uv lock # uv
# Shell
poetry shell # Poetry v1
source .venv/bin/activate # Poetry v2 + uvAt this stage, uv wasn’t the default installer yet, but we supported it. We checked the uv.lock generation as dependencies changed so we could build confidence before the cutover.
Milestone 3: Parity & Fixes
To ensure parity, the lockfiles must be in sync. To accelerate drift detection, I built an open source tool called cksync that diffs uv.lock and poetry.lock and produces actionable mismatches.
When uv.lock and poetry.lock differed, our playbook was consistent: add missing dependencies explicitly, or pin versions in pyproject.toml when resolution disagreed so production couldn’t surprise us mid-migration. Even better, check whether you can remove the dependency entirely! There were a few of those during our migration.
Milestone 4: Cutover & Cleanup
Once parity checks held up, we switched install flows in scripts/images to use uv.
The install scripts and docker builds on a stricter uv sync invocation:
1
2
3
4
5
# Development
uv sync --locked --active --only-dev
# Production
uv sync --locked --active--locked ensures that the uv.lock file is up to date, and fails the command if not. The failure message will prompt the user to run uv lock.
--active tells uv to use the activated virtualenv. That last bit matters because uv defaults to creating or using a project local .venv. Our docker builds already used a fixed /venv, and uv sync --active let us adopt uv without changing that layout:
1
2
3
4
5
6
ENV VIRTUAL_ENV=/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python -m venv $VIRTUAL_ENV
# ... later ...
RUN uv sync --locked --activeIf you do need uv itself to target a non-standard venv directory, uv also supports UV_PROJECT_ENVIRONMENT (upstream: PR #6834). We didn’t use it in our initial cutover, but it’s a good option and we’ll likely adopt it ourselves over time.
The cutover itself was straightforward. We updated the install scripts and deleted poetry.lock from the trunk.
After we closely monitored the release for issues. None came up, which gave us high confidence that the tools were in parity and the migration was successful.
Timeline (What This Took For Us)
This is the part most teams worry about: in our case, the migration was weeks, not quarters. The cutover was the easy part, most time went into tool updates.
End-to-end elapsed: ~4 weeks from first milestone comms to “uv is live” (mid‑March → mid‑April).
M1 (Prepare): ~1 week (poetry v2 + PEP 621 cleanup + guardrails).
M2 (Dual Run): ~2 weeks (introduce uv.lock, wire up CLI/install paths, keep locks in sync).
M3 (Parity & Fixes): ~1–2 weeks (diffs, pins, internal index/fork drift, upstream fixes).
M4 (Cutover & Cleanup): ~1–2 days (flip defaults, delete poetry.lock, ship + monitor).
If you can do a “big bang” migration
If you have a smaller project (or can afford a short freeze), you may be able to migrate in one shot instead of doing an incremental dual-run. A tool worth considering is migrate-to-uv, which can convert existing poetry / pip-tools / Pipenv / pip project metadata and generate a uv.lock that preserves your previously-locked versions.
For larger repos (or anything safety-critical), an incremental migration like the one in this post is often a safer route. You can run both tools and continuously verify that new changes resolve identically (same versions and sources), then flip the default once you’re confident. It is most valuable when you’re also migrating settings or relying on specific resolution behaviors that can subtly change between tools.
What’s Next
With dependency management no longer a bottleneck, the next opportunity is giving teams more ownership over how they build and deploy. Beyond fast locking and installs, uv also provides advanced project management capabilities like workspaces, which let you define multiple interconnected packages with a shared lockfile. We’re now using uv workspaces to offer backend teams a way to construct their own isolated Python projects from their existing Django apps within the monolith. This lets a team scope its dependencies, tests, and deployments to just the code it owns – enabling faster service startup, focused test suites, leaner releases, and more targeted context for AI-assisted development. The end result is smaller deployable units where teams have direct control over their memory footprint, CPU usage, and Docker image sizes. Watch this space for more on that work in a future post.
Conclusion
With uv, our dependency loop went from “schedule time for it” to “just do it”. Now locks and installs are fast, conflicts are rare, and upgrading packages no longer feels like a mini-migration. It pushed us toward cleaner, more standard packaging metadata, which makes the whole ecosystem easier to reason about.
We also want to acknowledge poetry. It gave us a solid, simple foundation for Python dependency management in a large repo, and it paved the way for a lot of the conventions uv builds on. If you’re on poetry today, you’re already on the path that made this migration possible.
Huge credit goes to Astral. Running uv at Rippling’s scale surfaced edge cases, and the Astral team was there to support us. By shipping fixes quickly, clarifying behavior, and improving the tooling in ways that benefit the broader Python community, not just our monolith. That kind of upstream partnership is what makes modern infrastructure feel possible.
If you want the nitty-gritty details, here are a few Astral PRs/issues:
uv support for UV_PROJECT_ENVIRONMENT (PR #6834)
read requirements from requires.txt when available (PR #6655)
revert wheel caching without range requests (locking was slower) (PR #6470)
Now we no longer pay the “slow lockfile tax” when we update a Python dependency.
Disclaimer
Rippling and its affiliates do not provide tax, accounting, or legal advice. This material has been prepared for informational purposes only, and is not intended to provide or be relied on for tax, accounting, or legal advice. You should consult your own tax, accounting, and legal advisors before engaging in any related activities or transactions.
Hubs
Author

Nick Grisafi
Rippling Engineer and 2024 Quality Week Winner
Nick is a Software Engineer on Rippling’s DevX team, building internal tools to keep hundreds of engineers productive at scale. He’s deeply passionate about Python and obsessed with making developer workflows faster and simpler. (edited)
See Rippling in action
Increase savings, automate busy work, and make better decisions by managing HR, IT, and Finance in one place.














1# This file is automatically @generated by poetry 1.8.5 and should not be changed by hand.