Three lines that turn a Python script into a binary
PEP 723 and uv let you ship single-file scripts with their dependencies baked in. Here's what that actually changes.
Three lines that turn a Python script into a binary
PEP 723 and uv let you ship single-file scripts with their dependencies baked in. Here's what that actually changes.
Last weekend I went through ~/bin and counted: 23 Python scripts, 12 of them broken. The code was fine. The venvs were gone, the system Python had moved, and the requirements.txt files mentioned packages that weren't installed anywhere on my machine.
I rewrote the broken ones in an afternoon using a pattern I'd known about for roughly a year without ever bothering to use — and that I should have adopted sooner.
Three lines at the top of the file:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["httpx", "rich"]
# ///Save it. chmod +x my_script.py. Run it with ./my_script.py. That's the whole thing.
What's actually happening
The shebang invokes uv as the interpreter. The -S flag is needed because env otherwise treats uv run --script as a single argument and tries to run a binary literally called "uv run --script", which doesn't exist. That cost me about ten minutes the first time.
When the script runs, uv reads the commented TOML block at the top, picks up the dependencies and the Python version requirement, creates an ephemeral venv in ~/.cache/uv/, downloads the right Python if it isn't installed, installs the dependencies, and runs your file. First run takes one to five seconds depending on what needs downloading. Every subsequent run reuses the cache and starts nearly instantly.
The format is standardized. PEP 723 (Final, January 2024) defines a generic "comment block" at the top of any Python file. The block starts with # /// script and ends with # ///. The content between those lines, with the leading # stripped from each line, is TOML. Two top-level fields are recognized: dependencies (a list of PEP 508 strings) and requires-python (a PEP 440 version specifier). An optional [tool] table lets each tool stash its own config under its own sub-table: [tool.uv], [tool.ruff], [tool.hatch]. Same semantics as the [tool] table in pyproject.toml.
That's the whole spec. You could write it out from memory after reading it twice.
What this actually unlocks
A few things I used to put off are now trivial.
Utility scripts in ~/bin. Each one declares its own deps. No shared venv to keep in sync, no per-script requirements-foo.txt lying around. Delete the script and everything related to it disappears with it.
Git hooks and pre-commit hooks. Custom ones, the kind I always wanted to write but never did because the venv setup made it not worth the bother. Now the hook is a single file and works on any machine that has uv.
Cron jobs on machines I don't fully control. Servers where Python is whatever it is, and adding to it feels intrusive. With this pattern, the script brings its own Python.
Sharing a script. "Copy this file, chmod +x, run it." That's the entire instruction set. No README, no "you'll need to create a venv first". I've shared scripts as gists three times since adopting this and not once had to follow up with installation help.
Three recipes I actually use
A FastAPI service, in one file:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["fastapi", "uvicorn[standard]"]
# ///
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)Run it with ./my_api.py and the service is up. Useful for prototypes, internal tools, the kind of thing that doesn't justify a Docker setup.
A CLI built with Typer:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["typer", "rich"]
# ///
import typer
from rich import print
app = typer.Typer()
@app.command()
def greet(name: str, formal: bool = False):
salutation = "Good day" if formal else "Hi"
print(f"[bold green]{salutation}, {name}![/bold green]")
if __name__ == "__main__":
app()This is the pattern I use the most. Drop it in ~/bin, give it a name without an extension (mv my_cli.py greet), and you have a binary that resolves its dependencies on demand.
A script that needs a private PyPI index:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["my-internal-sdk"]
#
# [[tool.uv.index]]
# url = "https://pypi.my-company.local/simple"
# ///The [[tool.uv.index]] block declares the private index. uv uses a first-index strategy by default: packages found on the private index are resolved from there exclusively — PyPI is never consulted for them. PyPI is only used for packages absent from all declared indexes. No global pip.conf to maintain.
When you need reproducibility
Two options. They solve different problems.
uv lock --script my_script.py creates a my_script.py.lock adjacent to the file. Subsequent runs use those pinned versions exactly. This is the right answer for anything that runs in CI or that someone else depends on.
The other option is lighter:
# /// script
# dependencies = ["requests"]
# [tool.uv]
# exclude-newer = "2025-06-01T00:00:00Z"
# ///uv only considers distributions published before the date. No lockfile, but the script is frozen in time. Good for archive scripts, blog post examples, demos that should still run identically three years from now. The full reference is in the uv docs.
What it doesn't do
It is not a project replacement. The shebang pattern is single-file. As soon as the script grows past one file, has tests, or shares code with siblings, you want a real project: uv init, pyproject.toml, the whole setup. The shebang stays useful at the project root for exposing a CLI, but the code itself lives in modules.
Windows ignores the shebang. On Windows you invoke uv run script.py explicitly. The PEP 723 block is still read; only the executable-from-the-shebang trick doesn't work.
The first run is slow by interactive standards. One to five seconds is fine for a script you call by hand. If you're running the script in a tight loop from a shell, ten thousand times per minute, keep a regular venv. Cold start adds up.
The biggest trade-off is that the target machine needs uv installed. You're swapping "you need Python and you need to know how to manage a venv" for "you need uv". For most developer contexts that's a clear win: uv is one binary, installs in seconds, and anyone working in modern Python ends up with it anyway. For a colleague who is a domain expert but not a Python expert, it's the same level of friction as before, just shifted to a different installer.
The thing I was wrong about
I dismissed this format for a long time. Single-file scripts felt second-class to me, real Python being projects, with tests, with a layout. That's still true for serious code. But there's a whole category of one-off, half-serious scripts that I now write differently and that I keep. They survive system rebuilds. They run on the laptop I haven't opened in six months. The friction that used to kill them, "ugh, I have to recreate the venv before this will run", is gone.
PEP 723 has been Final since January 2024. uv added preview support for --script in July 2024 and stabilized it in August 2024 (0.3.0). None of this is experimental anymore. I'm just writing this because I assumed I was the last holdout, and I keep finding people in the same situation: they've heard about it, they haven't tried it, they keep maintaining shared venvs they don't need.
References
A 14-page carousel version of this article (with code samples for FastAPI, Typer, and a private index recipe) is on my LinkedIn.
