Reproducible installs, fast CI, and shared internal libraries. Move from requirements.txt to pyproject and a lockfile, build wheels, publish to a private index, and stop hoping the build resolves the same twice.
A requirements.txt without pinned versions is a promise that today's build and tomorrow's build will be the same. It is not a promise Python can keep. Packaging is the part of the toolchain that decides whether "it worked yesterday" is a fact you can rely on or a coincidence you got used to.
Django>=5.2
celery
requests
This installs different code every week. Your dependencies have dependencies, and a patch release three levels down can change behaviour. Two developers running the same command a day apart get different trees, and CI gets a third. When something breaks, nothing in git changed — which is the worst possible debugging position.
The fix has two layers, and conflating them is the usual mistake:
pyproject.toml.[project]
name = "myshop"
version = "2.4.0"
requires-python = ">=3.12"
dependencies = [
"Django>=5.2,<6.0",
"psycopg[binary]>=3.2",
"celery>=5.4",
"redis>=5.2",
]
[project.optional-dependencies]
dev = ["pytest>=8.3", "pytest-django>=4.9", "ruff>=0.8", "mypy>=1.14"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
line-length = 100
target-version = "py312"
One file replaces setup.py, requirements.txt, requirements-dev.txt and most tool configuration. The version ranges express intent: >=5.2,<6.0 says "any 5.x, never 6.0 by accident". Upper bounds on the major version only — capping minor versions turns every upgrade into a manual chore and blocks security patches.
uv is a package manager written in Rust that resolves and installs dramatically faster than pip — the practical effect being that CI installs stop being something you optimise around.
uv sync # create venv + install from the lockfile
uv add "django>=5.2,<6.0" # add a dependency and update the lock
uv add --dev pytest
uv lock --upgrade-package django
uv run pytest # run inside the environment, no activation
uv.lock holds the entire resolved graph with hashes, for every platform you target. Commit it. Two important properties follow: an install can be verified against the hashes (so a compromised mirror cannot substitute a package), and it resolves identically on macOS, Linux and CI.
Migrating an existing project is usually two commands:
uv init --bare
uv add -r requirements.txt
Read the resulting pyproject.toml afterwards. It will list transitive packages your old file pinned by habit — those belong to the lockfile, not to your declared dependencies.
A wheel (.whl) is a built, ready-to-unpack archive. An sdist is source that must be compiled at install time. If a package with C extensions has no wheel for your platform, every install compiles it — which is why one container build takes four minutes and another takes twenty.
Practical consequences:
psycopg[binary] instead of building against system libpq, unless you have a reason.Once you run several Django projects, the same code appears in all of them: a house style for models, email helpers, an audit-log mixin. That is a package.
[project]
name = "acme-django-common"
version = "1.3.0"
dependencies = ["Django>=5.2,<6.0"]
[tool.hatch.build.targets.wheel]
packages = ["src/acme_common"]
uv build # produces dist/*.whl and dist/*.tar.gz
Three rules keep an internal library from becoming a liability:
You do not need a server. A directory of wheels served over HTTPS is a valid index, and most artifact registries speak the protocol.
[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.example.com/simple/"
UV_INDEX_INTERNAL_USERNAME=ci
UV_INDEX_INTERNAL_PASSWORD=${REGISTRY_TOKEN}
The dependency-confusion trap deserves a paragraph. If your internal package name also exists on public PyPI and your resolver may choose either, an attacker can publish a higher version publicly and get it installed. Defences: give internal packages a distinctive prefix you own, and pin each private package to its index explicitly rather than letting the resolver pick. Do not rely on ordering.
FROM python:3.13-slim AS base
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
--frozen is the important flag: install the lockfile exactly, and fail if it disagrees with pyproject.toml. Without it, a build can quietly resolve something new — which defeats the entire point of locking. The two-step copy keeps the dependency layer cached across source-only changes.
uv lock --upgrade # everything, deliberately
uv lock --upgrade-package django # one package
uv run pip-audit # known vulnerabilities in the tree
A workable rhythm: security patches when reported, a full --upgrade monthly on a branch with tests, and major versions read the changelog first. Automated dependency pull requests work well if your test suite is trustworthy; without one, they are a queue of unreviewable diffs.
Do not chase the newest release of everything. Do not sit three years behind either — the upgrade you postpone becomes the upgrade you cannot do.
pyproject.toml with major-version bounds only.--frozen.None of this is glamorous, and all of it is invisible when it works. That is the point: packaging done properly is the difference between a deploy you watch nervously and a deploy you do not think about.