Skip to content

Locking dependencies for PEP 723 single-file scripts in May 2026

May 8, 2026·Tim Hopper

PEP 723 plus uv made single-file Python scripts easy to share. Declare dependencies in a commented TOML block, drop the file in a gist, and uv run script.py builds an ephemeral environment and runs it without a project directory or activation step. Locking those dependencies so the script behaves the same six months from now is still awkward. The inline dependencies list pins the top-level requirements you specify and nothing else; the transitive graph gets re-resolved every time the script runs on a new machine.

Four locking options work today. Each one trades reproducibility against the single-file property that made PEP 723 worth using in the first place.

Pin top-level versions inline

The cheapest reproducibility step is to write your dependencies list with version specifiers instead of bare names. uv add --script will write specifiers for you when it adds a dependency:

uv add --script demo.py rich httpx

The resulting block:

demo.py
# /// script
# requires-python = ">=3.13"
# dependencies = [
#     "httpx>=0.28.1",
#     "rich>=15.0.0",
# ]
# ///

You can tighten those into exact pins (==) or compatible-release (~=) constraints. That gives you control over the libraries you import directly. It does nothing for what httpx and rich pull in transitively: the 10-package resolution graph behind those two names is invisible to the inline block.

This is the right floor: do it always. It is not enough on its own when you care about a specific transitive version or a release that broke compatibility two levels deep.

Cap resolution by date with exclude-newer

The next step is a time cap on the resolver. uv’s --exclude-newer flag tells the resolver to ignore any package release uploaded after a given timestamp, applied across the entire graph including transitives. You can set it from the command line, but for a self-contained script you want it in the file itself, in the [tool.uv] table inside the inline block:

demo.py
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "rich",
# ]
#
# [tool.uv]
# exclude-newer = "2024-01-01T00:00:00Z"
# ///
from importlib.metadata import version
print("rich version:", version("rich"))
$ uv run --script --refresh demo.py
Installed 4 packages in 8ms
rich version: 13.7.0

Without the cutoff, the same script on the same machine resolves rich to 15.0.0. The cap is doing real work, and it does it for the whole graph.

exclude-newer is not a lockfile. The resolver still picks the newest compatible release available on or before the cutoff, which means a yanked release inside the window or a registry change could still shift what you get. But it captures the most common cause of “the script broke six months later”: a transitive dependency shipped a breaking release. pip 26.0 (January 2026) added an equivalent --uploaded-prior-to flag for one-shot installs. It cannot yet be written into PEP 723 inline metadata.

For a script that needs “good enough reproducibility” but stays a single file, one line of inline TOML is the sweet spot.

Generate a sidecar lockfile with uv lock --script

When exclude-newer is not strict enough, uv can produce an actual lockfile next to the script. uv lock --script demo.py writes demo.py.lock:

$ uv lock --script demo.py
Resolved 10 packages in 1ms
$ ls
demo.py  demo.py.lock
$ wc -l demo.py demo.py.lock
      15 demo.py
     119 demo.py.lock

That lockfile is in uv’s own uv.lock format, not PEP 751. It pins exact versions and hashes for every package in the resolution graph. Subsequent uv run --script, uv add --script, and uv export --script commands reuse it.

The catch: demo.py.lock is a sidecar. Shipping a locked PEP 723 script is now a two-file problem, which dilutes the main reason to use PEP 723 at all. Paste the script alone into a Slack message or a gist and the lock is gone; the reader gets whatever the resolver hands them today.

Tools that consume PEP 723 scripts also do not all read the sidecar. uv reads its own .lock for uv run --script. pipx does not yet support lockfiles for scripts at all.

Export to a PEP 751 sidecar with uv export --script

If you want the same level of pinning in a tool-agnostic format, uv export --script demo.py --format pylock.toml -o pylock.demo.toml writes a PEP 751 lockfile:

$ uv export --script demo.py --format pylock.toml -o pylock.demo.toml
Resolved 10 packages in 1ms
$ wc -l pylock.demo.toml
      75 pylock.demo.toml
$ head -5 pylock.demo.toml
# This file was autogenerated by uv via the following command:
#    uv export --script demo.py --format pylock.toml -o pylock.demo.toml
lock-version = "1.0"
created-by = "uv"
requires-python = ">=3.13"

The output filename has to start with pylock. and end with .toml. uv enforces the convention from the spec; if you write demo-pylock.toml it errors out and tells you why.

PEP 751 is the standardized format. pip 26.1 supports installing from a pylock.toml, PDM and other tools either support or are adding support, and the format itself includes hashes and attestations. It is the closest thing to a portable script lockfile available today.

The catch is that no PEP 723 runner reads the pylock.demo.toml sidecar automatically when you uv run or pipx run the script. uv reuses its own .lock. To install the pinned environment from the pylock, you go around the script: uv pip install -r pylock.demo.toml or pip install -r pylock.demo.toml, then run the script in that environment. The pylock is the right format for the receiving end of a hand-off, not for invocation through a PEP 723 runner.

It is also still single-platform. pip’s own pip lock output explicitly warns it is “only guaranteed to be valid for the current python version and platform,” and the cross-platform story is uneven across tools. Shipping script.py and pylock.script.toml as a pair gives you reproducibility on the platform you locked on, with manual translation on every other platform.

Pick the right level of locking for your script

In May 2026, the practical answer depends on what reproducibility you need and how much you care about staying single-file:

For most scripts, top-level pins plus exclude-newer in the inline block is the right default. It is one file, the inline metadata stays readable, and it captures the most common failure mode (a transitive dependency ships a breaking release). It is not exact, but exact is rarely what scripts need.

For scripts where exact transitive pinning matters and you control the runtime, generate a sidecar with uv lock --script and ship both files. A directory or a tarball is the natural unit. If you need cross-tool portability, swap uv lock for uv export --format pylock.toml. Once you are shipping two files, PEP 723’s main benefit is gone and a pyproject.toml with uv.lock is often a better target.

For self-contained scripts where exact pinning matters and single-file is non-negotiable, May 2026 has no clean answer. The closest you can get inside one file is top-level pins plus exclude-newer, which covers more scripts than people give it credit for.

Learn more

Last updated on