I migrated a few Python projects from Poetry to uv. The conversion is mostly mechanical, so this focuses on what changed and why it was worth doing.
TLDR Link to heading
- uv is generally faster than Poetry for dependency resolution and installation
- Private registry auth goes from “install a keyring plugin” to “mount a credentials file”
- Your
pyproject.tomlmoves to PEP 621 standard format — no more[tool.poetry] - Use
>=lower bounds inpyproject.toml, let the lockfile handle exact pinning
Why switch Link to heading
Speed. Poetry’s resolver can be slow. In Docker builds, the poetry install layer often took 60–90 seconds. With uv, the same layer is usually much faster. uv is written in Rust, resolves in parallel, and downloads packages concurrently.
Simpler private registry auth. This was the biggest win for me. Poetry needed a keyring plugin to authenticate with private registries, installed either as a project dependency or via poetry self add. It had to exist in every Docker build, CI runner, and developer environment. With uv, mounting a credentials file as a Docker build secret is usually simpler to operate.
Better lockfile. uv.lock is cross-platform by default and reasonably human-readable (it’s TOML). Poetry’s lockfile was neither.
PEP standards. uv uses PEP 621 for project metadata and PEP 735 for dependency groups. Your pyproject.toml becomes portable — not locked to Poetry’s custom [tool.poetry] format.
What changes: pyproject.toml Link to heading
The conversion is mostly mechanical. Here’s a before/after:
-[tool.poetry]
-name = "my-service"
-version = "1.0.0"
-description = ""
-packages = [{ include = "my_service", from = "src" }]
-
-[tool.poetry.dependencies]
-python = "^3.12"
-fastapi = "^0.115.6"
-uvicorn = "^0.30.6"
-
-[tool.poetry.group.dev.dependencies]
-pytest = "^8.3.2"
-pytest-cov = "^5.0.0"
-
-[[tool.poetry.source]]
-name = "private"
-url = "https://my-registry.example.com/simple"
-priority = "supplemental"
-
-[build-system]
-requires = ["poetry-core"]
-build-backend = "poetry.core.masonry.api"
+[project]
+name = "my-service"
+version = "1.0.0"
+description = ""
+requires-python = ">=3.12,<3.13"
+dependencies = [
+ "fastapi>=0.115.6",
+ "uvicorn>=0.30.6",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.3.2",
+ "pytest-cov>=5.0.0",
+]
+
+[tool.uv]
+package = false
+
+[[tool.uv.index]]
+name = "private"
+url = "https://my-registry.example.com/simple"
+explicit = true
Key changes:
- Version syntax:
^1.2.3becomes>=1.2.3. The lockfile pins exact versions — yourpyproject.tomljust sets lower bounds. - Dependency groups:
[tool.poetry.group.dev.dependencies]becomes[dependency-groups] dev = [...]. - Private sources:
[[tool.poetry.source]]becomes[[tool.uv.index]]withexplicit = true. Any package from that index needs an entry in[tool.uv.sources]. - Build system: For applications (deployed as containers), remove the build system entirely and add
package = false. For libraries (published to a registry), swappoetry-coreforhatchling.
Tool config sections like [tool.ruff], [tool.pytest.ini_options], and [tool.mypy] stay exactly the same.
What changes: Dockerfile Link to heading
-RUN curl -sSL https://install.python-poetry.org | python3 -
-COPY poetry.lock pyproject.toml ./
-RUN poetry install --without dev --no-interaction
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/
+COPY pyproject.toml uv.lock ./
+RUN --mount=type=secret,id=credentials \
+ uv sync --frozen --no-install-project --no-dev
+COPY ./src ./src
+RUN uv sync --frozen --no-dev
The two-step uv sync gives you better layer caching — dependencies are cached separately from your application code. The --frozen flag ensures the lockfile is used as-is without attempting to update it.
What changes: CI Link to heading
-- uses: snok/install-poetry@v1
- with:
- version: 1.8.3
-- run: poetry install --with dev
-- run: poetry run pytest
++ uses: astral-sh/setup-uv@v7
++ run: uv sync --group dev
++ run: uv run pytest
astral-sh/setup-uv handles caching automatically — no separate actions/cache step needed.
For publishing libraries, poetry publish --build becomes uv build && uv publish.
One thing to watch out for Link to heading
Don’t carry over == pins into your pyproject.toml. This often happens when Poetry’s resolved versions are copied as exact pins. That’s what the lockfile is for. Use >= lower bounds — this is what uv add defaults to. Pinning with == makes upgrades painful and defeats the purpose of having a lockfile.
Was it worth it? Link to heading
For me, yes. Docker builds are faster, CI is simpler, and the config file aligns with standard Python metadata. Each repo typically takes a few hours, mostly for testing.