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


[PEP 723](https://pydevtools.com/handbook/explanation/what-is-pep-723.md) plus [uv](https://pydevtools.com/handbook/reference/uv.md) 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:

```bash
uv add --script demo.py rich httpx
```

The resulting block:

```python {filename="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`](https://pydevtools.com/handbook/how-to/how-to-use-exclude-newer-for-reproducible-python-environments.md) 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:

```python {filename="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"))
```

```console
$ 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](https://pydevtools.com/handbook/reference/pip.md) [26.0](https://pydevtools.com/blog/did-pip-26-close-the-gap-with-uv.md) (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`:

```console
$ 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](https://pydevtools.com/handbook/explanation/what-is-pep-751.md). 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](https://pydevtools.com/handbook/reference/pipx.md) 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:

```console
$ 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`](https://pydevtools.com/handbook/how-to/how-to-install-from-a-pylock-toml-lockfile-with-pip.md), [PDM](https://pydevtools.com/handbook/reference/pdm.md) 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`](https://pydevtools.com/handbook/how-to/how-to-use-a-uv-lockfile-for-reproducible-python-environments.md) is often a better target.

For [self-contained scripts](https://pydevtools.com/handbook/how-to/how-to-write-a-self-contained-script.md) 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

- [How to write self-contained Python scripts using PEP 723](https://pydevtools.com/handbook/how-to/how-to-write-a-self-contained-script.md) covers the basics if you're new to inline metadata
- [How to convert a script with requirements.txt to PEP 723 inline metadata](https://pydevtools.com/handbook/how-to/how-to-convert-a-script-with-requirements-txt-to-pep-723-inline-metadata.md) walks through migrating a sidecar `requirements.txt`
- [How to use `--exclude-newer` for reproducible Python environments](https://pydevtools.com/handbook/how-to/how-to-use-exclude-newer-for-reproducible-python-environments.md) covers the time-cap option in detail
- [What is PEP 751?](https://pydevtools.com/handbook/explanation/what-is-pep-751.md) explains the standardized lockfile format
