Adam Innes · Blog

Python Finally Has a Standard Lock File Format

· 7 min · python, packaging, pip, dependencies, supply chain

Every Python team I’ve worked with has at some point written a requirements.txt, committed it, and quietly assumed it meant the next machine would install the same thing. It usually does. Then one day it doesn’t, and you spend an afternoon working out which transitive dependency shipped a new patch release between your laptop and the CI runner.

That gap has now been formally closed. PEP 751 was accepted on 31 March 2025 and defines a standard lock file format for Python called pylock.toml. It is the first packaging standard that describes, in a way every tool can read, exactly which files an installer should fetch and what their hashes must be. Brett Cannon wrote it, and it is the successful second run at an idea that failed the first time: PEP 665 proposed something similar back in 2021 and was rejected, largely because it had no support for source distributions.

Why requirements.txt was never a lock file

The honest answer is that requirements.txt is a pip feature that got adopted as a convention. PEP 751’s own motivation section puts it bluntly, describing the format as “not a standard but is supported by convention”, designed very much around pip’s needs. Nothing in any standard says what a requirements file is, which is why every tool that emits one emits a slightly different dialect.

The bigger problem is what it does and doesn’t guarantee. A file full of == pins gets you closer to repeatable installs, and pip freeze will pin the transitive tree as well as your direct dependencies, but a pin still trusts whatever the index hands you for that version. pip’s own repeatable installs page is refreshingly honest about this: pinning trusts the location you fetch from and the certificate chain behind it, and nothing else.

You can do better, because pip has had hash-checking mode since version 8. Add --hash=sha256:... lines to each requirement, or pass --require-hashes, and pip will verify every file it downloads against a hash you control. It is the right thing to do, and it is also all or nothing and quite strict: once one requirement carries a hash, every requirement needs one, every transitive dependency needs to be spelled out, and everything has to be pinned to an exact version, URL or path. That is a lot of hand assembly for something the resolver already knew when it picked the versions. In practice, most teams never turn it on, which means the security property is opt-in and mostly opted out of.

So the format is unstandardised, insecure by default, and each tool that tried to do better (Poetry, PDM, pip-tools, uv) invented its own lock file. That fragmentation is the part that actually costs money: your dependency scanner, your cloud build service and your auditing tool each have to pick which formats to support, and you get quietly locked in to whichever one they chose.

What a real lock file buys you

Two things, mostly. The first is reproducibility: a fully resolved set of packages with no resolver required at install time. The installer does not re-solve anything, it just reads the file and fetches. That makes installs faster and, more importantly, makes them boring, because there is no clever algorithm left to make a different choice on Tuesday than it made on Monday.

The second is supply chain integrity. When the file records a hash for every artifact, a tampered mirror, a compromised index or a file that changed without its version changing all fail loudly instead of silently installing. PEP 751 makes that the default rather than an advanced flag: for archives, sdists and wheels, the hashes table is required and must contain at least one entry, with sha256 the recommended algorithm.

What PEP 751 actually specifies

The file is TOML, machine-generated, and named pylock.toml, or pylock.something.toml when you want more than one (a dev lock next to a production lock, say). It is designed to be readable by a human doing an audit, and readable by tools that are not written in Python, which matters if your hosting provider wants to install your dependencies in Go.

At the top it records lock-version, currently 1.0, and created-by, naming the tool that produced it. Optionally it records requires-python, an environments array of environment markers describing which platforms the file covers, plus the extras and dependency-groups it supports. Then comes an array of [[packages]] tables, one per package that might be installed, each with a name, usually a version, an optional marker deciding whether this package applies to the current environment, and its source. A source is a wheel list, an sdist, a direct archive, a VCS checkout pinned to a commit-id, or a local directory. Wheels and sdists carry file name, size, upload time and hashes, and packages can carry the base URL of the index they were found on plus attestation-identities, which records the Trusted Publisher identities attached to those files.

One design decision worth knowing about is that markers do double duty. PEP 751 extends marker syntax so a package entry can say it applies only when a given extra or dependency group was requested, which is what lets a single file serve several use cases instead of forcing you into four separate requirements files. Tools are allowed to skip that and write single-use files only.

What it deliberately leaves out

This is the part teams get wrong when they first read about it. pylock.toml is an install-time artifact, not a replacement for your tool’s own lock file, and the PEP says so in several ways.

It does not fully replace requirements files, because requirements files can carry install-time options like --index-url and --constraint, can reference other files with -r, and can use environment variables. None of that is in scope here. It does not record the requirements that were used as inputs to produce the lock, for simplicity. It does not lock the build requirements of source distributions, which an earlier draft attempted and then dropped as too full of edge cases. It does not make two different lockers produce the same output from the same inputs, so switching tools changes the resulting file even though any installer can consume either one. And it explicitly does not protect the file from tampering: if someone edits your committed lock file to point at a malicious release, the format will happily carry it, so signing or review is still your job.

The hope stated in the PEP is that some tools will be able to drop their internal format entirely, but the realistic near-term role is as an export target, like a shipping container rather than a workshop.

Where pip stood in May 2025

Carefully, this bit. pip 25.1, released on 26 April 2025, added an experimental pip lock command implementing PEP 751. pip 25.1.1 followed on 2 May and changed nothing about it. So as of today, pip can write one of these files:

python -m pip lock -e .
python -m pip lock -r requirements.txt -o pylock.dev.toml

What pip cannot yet do is install from one. There is no pip install -r pylock.toml in 25.1.1. The command is also honest about its limits: pip’s docs note that the generated file is only guaranteed to be valid for the current Python version and platform, so a lock produced on your Mac is not a lock for your Linux runners. Treat it as an early look rather than something to build a deployment pipeline on this month.

What it means if your team uses something else

Not much needs to change today, and that’s fine. If you use Poetry, PDM, uv or pip-tools, keep using it. The format you want to watch for is an export command that writes pylock.toml, because that’s the file you’ll hand to things that are not your package manager: a scanner, a CI cache, a container build, a provider that installs on your behalf, an auditor who wants to see exactly which wheel with which hash went into last month’s release. Several of the fields in the spec are credited as inspiration from PDM, Poetry and uv, which is a good sign that the tools can write it without contorting themselves.

If you’re still on plain requirements.txt with no hashes, the standard is a decent prompt to fix the weaker problem first. Turning on hash-checking mode is available to you right now, it catches the attack that matters, and it makes the eventual move to pylock.toml feel like a file format change instead of a policy change.

The thing I like most about PEP 751 is how narrow it is. It does not try to be a project manifest, a resolver spec or a workflow tool. It writes down the answer, with hashes, in a format anything can read, and leaves the arguing about how to get that answer to the tools. Ten years of Python packaging debates suggest that narrowness is exactly why this one got accepted. The PyPA specification is the version to bookmark, since that’s where the format lives now that the PEP is final.

← all posts