Skip to content

Instantly share code, notes, and snippets.

@mikeckennedy
Last active August 30, 2026 11:45
Show Gist options
  • Select an option

  • Save mikeckennedy/2f45134b3281b3ccf2729e3a7c21ea4f to your computer and use it in GitHub Desktop.

Select an option

Save mikeckennedy/2f45134b3281b3ccf2729e3a7c21ea4f to your computer and use it in GitHub Desktop.
prune_uv_pythons.py - Prune uv-managed Python installs, keeping only the newest patch per minor version
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Prune uv-managed Python installs, keeping only the newest patch per minor version.
Why: uv installs a fresh patch release whenever one lands and never removes the old
one, so `uv python list` slowly fills with 3.13.12, 3.13.13, 3.13.14, 3.13.15, and so
on. Each of those is a complete standalone copy of Python sitting in
~/.local/share/uv/python (run `du -sh` on it to see what you're carrying). This script
deletes the stale patches and leaves the newest one for every minor version you have.
What it keeps: the highest patch in each (implementation, minor version, variant,
platform) group. Variants are their own groups, so cpython 3.14.7 and
cpython 3.14.5+freethreaded both survive - pruning them together would break the
python3.14t symlink. Rows marked `<download available>` are ignored, so an
uninstalled 3.15.0rc1 never counts as "the latest 3.15." Final releases outrank
prereleases at the same patch, so 3.15.0 beats 3.15.0rc1. Duplicate rows for one
install (the bin symlink plus the cpython-3.14-... version dir) collapse to a single
uninstall.
Uninstalls go through `uv python uninstall <full install key>`, one call per version,
so a single failure doesn't block the rest.
Caveat: if a project pins an exact patch in .python-version and that patch gets
pruned, the next `uv sync` re-downloads it.
uv run prune_uv_pythons.py # dry run, shows what would go
uv run prune_uv_pythons.py --apply # actually uninstall (asks first)
uv run prune_uv_pythons.py --keep 2 # keep the 2 newest patches per minor
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from collections import defaultdict
# cpython-3.14.5+freethreaded-macos-aarch64-none
KEY_RE = re.compile(
r'^(?P<impl>[A-Za-z]+)-'
r'(?P<version>\d+(?:\.\d+)*(?:a|b|rc)?\d*)'
r'(?:\+(?P<variant>[^-]+))?-'
r'(?P<platform>.+)$'
)
VERSION_RE = re.compile(r'^(\d+)\.(\d+)(?:\.(\d+))?(?:(a|b|rc)(\d+))?$')
PRE_RANK = {'a': 0, 'b': 1, 'rc': 2}
def sort_key(version: str) -> tuple:
m = VERSION_RE.match(version)
if not m:
return (0, 0, 0, 0, 0)
major, minor, patch, pre, pre_n = m.groups()
# final releases outrank rc/b/a of the same patch
return (int(major), int(minor), int(patch or 0), PRE_RANK.get(pre, 3), int(pre_n or 0))
def list_installed(uv: str) -> dict[str, dict]:
cmd = [uv, 'python', 'list', '--managed-python', '--all-versions', '--only-installed']
try:
out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
except FileNotFoundError:
sys.exit(f'error: {uv!r} not found on PATH')
except subprocess.CalledProcessError as e:
sys.exit(f'error: {" ".join(cmd)} failed\n{e.stderr.strip()}')
installed: dict[str, dict] = {}
for line in out.splitlines():
line = line.strip()
if not line:
continue
key, _, rest = line.partition(' ')
rest = rest.strip()
if not rest or rest.startswith('<download'): # not actually installed
continue
m = KEY_RE.match(key)
if not m:
print(f'warning: skipping unparsed entry {key!r}', file=sys.stderr)
continue
entry = installed.setdefault(key, dict(m.groupdict(), key=key, paths=[]))
entry['paths'].append(rest) # same install can show up under several symlink dirs
return installed
def plan(installed: dict[str, dict], keep: int) -> tuple[list[dict], list[dict]]:
groups: dict[tuple, list[dict]] = defaultdict(list)
for entry in installed.values():
m = VERSION_RE.match(entry['version'])
minor = f'{m.group(1)}.{m.group(2)}' if m else entry['version']
groups[(entry['impl'], minor, entry['variant'] or '', entry['platform'])].append(entry)
keepers, doomed = [], []
for group_key in sorted(groups):
ordered = sorted(groups[group_key], key=lambda e: sort_key(e['version']), reverse=True)
keepers.extend(ordered[:keep])
doomed.extend(ordered[keep:])
return keepers, doomed
def label(entry: dict) -> str:
return entry['impl'] + (f'+{entry["variant"]}' if entry['variant'] else '')
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--apply', action='store_true', help='actually uninstall (default: dry run)')
ap.add_argument('--keep', type=int, default=1, metavar='N', help='newest N patches to keep per minor (default: 1)')
ap.add_argument('--yes', action='store_true', help='skip the confirmation prompt')
ap.add_argument('--uv', default='uv', help='path to the uv binary')
args = ap.parse_args()
if args.keep < 1:
sys.exit('error: --keep must be at least 1')
installed = list_installed(args.uv)
if not installed:
print('No managed Python installs found.')
return 0
keepers, doomed = plan(installed, args.keep)
width = max(len(label(e)) for e in installed.values()) + 2
for entry in sorted(keepers, key=lambda e: (e['impl'], e['variant'] or '', sort_key(e['version'])), reverse=True):
print(f' keep {label(entry):<{width}} {entry["version"]}')
for entry in sorted(doomed, key=lambda e: (e['impl'], e['variant'] or '', sort_key(e['version'])), reverse=True):
print(f' remove {label(entry):<{width}} {entry["version"]}')
if not doomed:
print('\nNothing to prune.')
return 0
print(f'\n{len(doomed)} install(s) to remove, {len(keepers)} to keep.')
if not args.apply:
print('\nDry run. Re-run with --apply to uninstall, or do it by hand:')
print(' uv python uninstall ' + ' '.join(e['key'] for e in doomed))
return 0
if not args.yes:
if input('\nUninstall these? [y/N] ').strip().lower() not in {'y', 'yes'}:
print('Aborted.')
return 1
failures = 0
for entry in doomed:
print(f'\n$ uv python uninstall {entry["key"]}')
result = subprocess.run([args.uv, 'python', 'uninstall', entry['key']])
if result.returncode != 0:
failures += 1
print(f' failed: {entry["key"]}', file=sys.stderr)
return 1 if failures else 0
if __name__ == '__main__':
raise SystemExit(main())
@mikeckennedy

Copy link
Copy Markdown
Author

Prune uv-managed Python installs, keeping only the newest patch per minor version. Why: uv installs a fresh patch release whenever one lands and never removes the old one, so uv python list slowly fills with 3.13.12, 3.13.13, 3.13.14, 3.13.15, and so on. Each of those is a complete standalone copy of Python sitting in ~/.local/share/uv/python (run du -sh on it to see what you're carrying). This script deletes the stale patches and leaves the newest one for every minor version you have. What it keeps: the highest patch in each (implementation, minor version, variant, platform) group. Variants are their own groups, so cpython 3.14.7 and cpython 3.14.5+freethreaded both survive - pruning them together would break the python3.14t symlink. Rows marked <download available> are ignored, so an uninstalled 3.15.0rc1 never counts as "the latest 3.15." Final releases outrank prereleases at the same patch, so 3.15.0 beats 3.15.0rc1. Duplicate rows for one install (the bin symlink plus the cpython-3.14-... version dir) collapse to a single uninstall.

Uninstalls go through uv python uninstall <full install key>, one call per version, so a single failure doesn't block the rest.

Caveat: if a project pins an exact patch in .python-version and that patch gets pruned, the next uv sync re-downloads it.

    uv run prune_uv_pythons.py            # dry run, shows what would go
    uv run prune_uv_pythons.py --apply    # actually uninstall (asks first)
    uv run prune_uv_pythons.py --keep 2   # keep the 2 newest patches per minor

@antonalekseev

Copy link
Copy Markdown

@mikeckennedy Thanks for a useful script! I tried it in the wild and stumbled upon a caveat. The script doesn't account for uv tool installations. For example, given

❯ uv tool list --show-python
[...]
jupyter-core v5.8.1 [CPython 3.13.2]
- jupyter
- jupyter-migrate
- jupyter-troubleshoot
[...]

the script still sugests to remove 3.13.2

❯ prune_uv_pythons.py
  keep    cpython   3.13.5
  keep    cpython   3.12.11
  keep    cpython   3.8.20
  remove  cpython   3.13.2
  remove  cpython   3.12.9

2 install(s) to remove, 3 to keep.

Dry run. Re-run with --apply to uninstall, or do it by hand:
  uv python uninstall cpython-3.12.9-macos-aarch64-none cpython-3.13.2-macos-aarch64-none

and uv tool unistall proceed without a warning as well

❯ uv python uninstall cpython-3.13.2-macos-aarch64-none
Searching for Python versions matching: cpython-3.13.2-macos-aarch64-none
Uninstalled Python 3.13.2 in 144ms
 - cpython-3.13.2-macos-aarch64-none
❯ jupyter lab
zsh: /Users/user/.local/bin/jupyter: bad interpreter: /Users/user/.local/share/uv/tools/jupyter-core/bin/python3: no such file or directory

A feasible solution would be to resolve the format used by uv tool list --show-python (CPython 3.13.2) into cpython-3.13.2-macos-aarch64-none and exclude it from the doomed ones. Theoretically, it's possible to do it in an unambiguous way, for an os-arch pair is constant within the machine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment