Post

Python Build with uv and Poetry

The modern lockfile-based Python workflow with uv and Poetry, the files they need, how to lint, scan and test, and a complete GitHub Actions pipeline.

Python Build with uv and Poetry

uv and Poetry replace the pip + venv + pip-tools + build + twine combination with one tool that manages the virtual environment, resolves and locks dependencies, builds and publishes. uv (from Astral, written in Rust) has become the fast default for new projects; Poetry remains common in existing codebases.

This post is part of Build Ecosystems by Programming Language. The classic workflow is covered in pip, setuptools and build.

At a glance

CategoryCommon tools
Project managersuv, Poetry, PDM, Hatch
Build backendshatchling, uv_build, poetry-core, pdm-backend, setuptools
Package registryPyPI, private Artifactory / Nexus / devpi
Build filespyproject.toml, uv.lock / poetry.lock, .python-version, uv.toml, poetry.toml
Lint and formatRuff, mypy / Pyright
SAST (open source)Bandit, Semgrep, CodeQL, SonarQube Community Build
SAST (commercial)Checkmarx One, Veracode, Snyk Code, SonarQube Server
SCApip-audit (on an exported requirements file), OSV-Scanner (reads uv.lock / poetry.lock), Trivy, Snyk, Mend
FrameworksFastAPI, Django, Flask
Test and coveragepytest, pytest-cov
Build outputwheel (.whl), sdist (.tar.gz)
CI cache~/.cache/uv, ~/.cache/pypoetry

How the build works

In CI, each stage of the generic build pipeline maps to a uv / Poetry command, plugin or scanner. Press play, or step through it:

  1. Source + lockfile: A commit triggers CI. pyproject.toml and a universal lockfile (uv.lock or poetry.lock) pin every dependency for every platform.
  2. Sync the environment: uv python install reads .python-version, then uv sync --locked (or poetry sync) makes .venv match the lock and fails if it is stale.
  3. Lint and format: uv run ruff check . and uv run ruff format --check . lint the code and check formatting.
  4. Type check: uv run mypy src checks type annotations before any test runs.
  5. Test and coverage: uv run pytest --cov=src --cov-fail-under=80 runs the tests and fails below 80% line coverage.
  6. Security scans in parallel: uv run bandit checks our code, uv export plus uvx pip-audit checks the locked dependencies (OSV-Scanner reads uv.lock directly), and Gitleaks scans for secrets.
  7. Quality gate: Lint, types, tests with the coverage gate and both scanners must pass before anything is built.
  8. Fail the build (fail): A failed check stops the pipeline at the gate, so nothing is packaged, signed or published.
  9. Build the distributions (pass): uv build (or poetry build) writes the wheel and sdist to dist/.
  10. SBOM and signing (pass): uvx --from cyclonedx-bom cyclonedx-py environment .venv writes sbom.json, and the distributions are signed with Sigstore cosign.
  11. Publish (pass): uv publish (or poetry publish) uploads dist/ to PyPI or a private index.
  • The lockfile is universal: it records versions for every platform and Python version, so the same lock works on developer laptops and Linux CI runners.
  • sync makes the environment match the lock exactly, removing anything extra.
  • uv can also install Python itself (uv python install), which removes the need for pyenv on build agents.

Same task, two tools

TaskuvPoetry (2.x)
New projectuv init demopoetry new demo
Add a dependencyuv add fastapipoetry add fastapi
Add a dev dependencyuv add --dev pytestpoetry add --group dev pytest
Install from lock (CI)uv sync --lockedpoetry sync (fails if lock is stale)
Check lock is currentuv lock --checkpoetry check --lock
Run a commanduv run pytestpoetry run pytest
Builduv buildpoetry build
Publishuv publishpoetry publish
Export requirementsuv export --format requirements-txtpoetry export (plugin)

Build files

FilePurposeCommit?
pyproject.tomlMetadata, dependencies, dependency groups, backend, tool configYes
uv.lock / poetry.lockResolved versions and hashes for all platformsYes
.python-versionPython version used by uv / pyenvYes
uv.toml / poetry.tomlTool settings (index URLs, venv location)Yes, without credentials
.venv/, dist/Environment and build outputNo

Commands by category

1
2
3
4
5
6
7
8
uv sync --locked                                 # reproducible install (fails on stale lock)
uv run ruff check . && uv run ruff format --check .   # lint + format
uv run mypy src                                  # type check
uv run bandit -r src -ll                         # SAST
uv export --format requirements-txt --no-hashes -o req.txt && uvx pip-audit -r req.txt   # SCA
uv run pytest --cov=src --cov-fail-under=80      # tests + coverage gate
uvx --from cyclonedx-bom cyclonedx-py environment .venv -o sbom.json   # SBOM
uv build                                         # wheel + sdist

Minimal project

1
2
3
4
5
6
7
8
9
10
11
12
13
# pyproject.toml for uv
[project]
name = "demo"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["fastapi[standard]>=0.115"]

[dependency-groups]
dev = ["pytest", "pytest-cov", "ruff", "mypy", "bandit"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Same project for Poetry 2.x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[project]
name = "demo"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["fastapi[standard]>=0.115"]

[tool.poetry.group.dev.dependencies]
pytest = "*"
pytest-cov = "*"
ruff = "*"
bandit = "*"

[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"

Frameworks

FrameworkStart a project with uv
FastAPIuv init api && cd api && uv add "fastapi[standard]" then uv run fastapi dev main.py
Djangouv init site && cd site && uv add django then uv run django-admin startproject mysite .
Flaskuv init app && cd app && uv add flask then uv run flask --app app run

CI with GitHub Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
name: python-uv-ci
on: [push, pull_request]
permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
        with:
          enable-cache: true                   # caches ~/.cache/uv keyed on uv.lock

      - run: uv python install                 # reads .python-version
      - run: uv sync --locked
      - run: uv run ruff check . && uv run ruff format --check .
      - run: uv run bandit -r src -ll
      - run: uv export --format requirements-txt --no-hashes -o req.txt && uvx pip-audit -r req.txt
      - run: uv run pytest --cov=src --cov-fail-under=80
      - run: uv build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

For Poetry, replace the uv steps with pipx install poetry, poetry sync, poetry run pytest and poetry build, and cache ~/.cache/pypoetry with actions/setup-python (cache: poetry).

Best practices

  • Commit the lockfile and use uv sync --locked / poetry sync in CI so a stale lock fails.
  • Use dependency groups for dev, test and lint tools instead of separate requirements files.
  • Pin Python with .python-version, and let uv install it on runners.
  • Use uvx for one-off tools (pip-audit, cyclonedx) so they do not end up in the project lock.
  • In containers, copy pyproject.toml and the lockfile first, run uv sync --locked --no-dev, then copy the source, so the dependency layer stays cached.
  • Point both tools at your private index ([[tool.uv.index]] or poetry source add) instead of PyPI directly.
This post is licensed under CC BY 4.0 by the author.