Python Packaging #7 Team Conventions and Tool Choice: uv, poetry, pip

5 min read

Packaging alone is a matter of taste; packaging on a team is a matter of convention. The final post of the series covers the two things needed to establish that convention — the criteria for choosing a tool, and the standard patterns for team, CI, and Docker — then looks back at the whole journey.

Tool choice: three candidates on one table #

As of 2026 the real candidates are three:

uvpoetrypip + venv
Lockinguv.lock (built in)poetry.lock (built in)Separate tool needed (pip-tools)
Python version managementBuilt in (uv python)None (separate tool)None (separate tool)
SpeedThe baseline (fastest)Several times slowerMuch slower once lock compilation counts
Standards compliancepyproject.toml + PEP 735/751Converged on standards since 2.xThe reference point
StrengthUnified workflow, speedMature ecosystem, long track recordEverywhere, zero dependencies

The verdict splits by situation:

  • New projects: uv. As seen in #4, environment, lock, and Python version end in one tool, and the ecosystem’s center of gravity is already here.
  • Teams running well on poetry have no reason to rush. Poetry converged on the standard formats in 2.x and remains actively maintained. Migration costs less in the tool swap itself than in retraining habits and converting CI scripts — so “uv starting with the next new project” is the realistic path.
  • pip + venv remains the right answer in some places: constrained environments where external tools can’t be installed, or minimal setups that must run on the standard library alone. There, supplement locking with pip-tools or the standard pylock.toml from #5.

What to avoid is not any tool but mixing them. When one person runs pip install and another runs uv add in the same repository, declaration and lock start drifting apart. One tool per repository, nailed down in the README’s first lines — that is where convention starts.

CI: caching and frozen verification #

Layer the packaging conventions onto the CI from Testing #7 and it looks like this:

.github/workflows/ci.yml
# .github/workflows/ci.yml (the essential part)
steps:
  - uses: actions/checkout@v4
  - uses: astral-sh/setup-uv@v5
    with:
      enable-cache: true         # reuse the uv cache across runners
  - run: uv sync --frozen        # reproduce the lock exactly, fail on drift
  - run: uv run pytest

Two points. The cache reuses uv’s global cache across CI runners, cutting installs to seconds. --frozen is the enforcement mechanism for #5’s principle — it catches the PR that edited dependencies but forgot to re-lock. Add a monthly dependency-upgrade PR (Renovate, Dependabot) and a pip-audit scan, and the whole operating routine from #5 is automated.

Docker: the same conventions inside the image #

In production images the principles are unchanged: exactly the lock, without dev dependencies, with the cache alive.

Dockerfile
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /app

# dependency layer: cache hit while the lock is unchanged
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

# app layer: code-only changes rebuild from here
COPY . .
RUN uv sync --frozen --no-dev

CMD ["uv", "run", "python", "main.py"]

The key is separating dependency installation and code copy into layers. A build that only touched code passes the dependency layer straight from cache, cutting build times substantially. --no-dev keeps the dev group — pytest, ruff — out of the production image. The split between extras and dependency-groups from #3 pays off right here.

The team convention checklist #

Measured against “survives a new teammate joining,” the conventions are:

  1. One tool per repository: name it in the README and delete other tools’ commands from the docs.
  2. What gets committed: pyproject.toml and uv.lock committed, .venv in .gitignore. CI rejects PRs without a lock.
  3. Declare ranges, lock exactly: only direct dependencies in pyproject.toml; transitive ones belong to the lock.
  4. Pin the Python version via .python-version: no more “but I’m on 3.12.”
  5. Upgrades as independent PRs on a cadence: a feature PR with lock changes mixed in gets asked to split in review.
  6. CI frozen, production no-dev: reproduction is mechanical; development tools stay out of the image.

Closing the series #

Retracing the seven posts:

  • #1 Virtual environments: the structural cause of broken environments, venv, and what activate really is.
  • #2 pip and requirements.txt: the traditional workflow, and the limit of intent and snapshot sharing one file.
  • #3 pyproject.toml: the standard home for intent, and the extras versus dependency-groups split.
  • #4 uv: the modern workflow folding environment, install, lock, and Python versions into one tool.
  • #5 Dependency locking: reading uv.lock, the frozen principle, upgrade routines, and the standard pylock.toml.
  • #6 Publishing: standing on the maker’s side — wheels, PyPI, Trusted Publishing.
  • #7 Team conventions: binding it all into tool-choice criteria and the CI, Docker, and team checklists.

Packaging is not a glamorous topic. But the state where “anyone can clone, sync, and get the same environment on any machine” is the floor under every piece of code, every test, and every deployment built on top. May this series help make that floor solid. What to build on it is already waiting: the series that guard your code with tests, erase repetition with automation, and answer questions with data analysis all run on this very floor.

X