TL;DR: I needed to repoint a batch of uv projects at a new package registry without changing a single resolved version. uv lock --no-upgrade re-resolves anyway when the index changes, so I rewrote the URLs in pyproject.toml and uv.lock with sed and verified with uv lock --locked.
Motivation Link to heading
I was migrating Python projects at work to a new package registry. The packages had been copied across byte-identical, so the only thing that should change in each project was the URL. I’d promised reviewers config-only diffs: same packages, same versions, new host.
The surprise Link to heading
My first attempt was the obvious one:
# edit the index url in pyproject.toml, then:
uv lock --no-upgrade
I’d read --no-upgrade as “keep all pinned versions”. It doesn’t mean that here. Changing the index source triggers a fresh resolution, and the resolver bumped a handful of packages in most projects I tried it on. Version drift was exactly what I’d said wouldn’t happen.
What worked Link to heading
Skip uv lock for the swap entirely. If the packages on the new index are byte-identical, the existing lockfile entries stay valid after a URL rewrite:
sed -i '' \
-e 's|old-host/old-repo-b|new-host/new-repo|g' \
-e 's|old-host/old-repo|new-host/new-repo|g' \
pyproject.toml uv.lock
uv lock --locked # exit 0 means the lockfile is still consistent
Two details cost me a re-run each:
- Pattern order. One of my old repo names was a prefix of another, so the longer pattern has to run first or the shorter one mangles it.
- Don’t anchor on
https://. Lockfiles and docs contain scheme-less registry URLs too. My first scheme-anchored pattern silently skipped one, and I only found it in a later sweep.
uv lock --locked is the safety net: it checks the lockfile against pyproject.toml without writing anything, so a bad sed fails loudly instead of shipping.
Pull and publish are separate URLs Link to heading
A single uv index entry can point reads and writes at different places, which I hadn’t used before. My new setup pulled from one repo and published to another, and uv handles that on one index:
[[tool.uv.index]]
name = "internal"
url = "https://registry.example.com/python/simple"
publish-url = "https://registry.example.com/python-publish/"
Explicit vs default Link to heading
With explicit = true, the index is only consulted for packages you map to it in [tool.uv.sources]; everything else still resolves from pypi.org. Setting default = true instead replaces pypi.org so every dependency routes through your index:
[[tool.uv.index]]
name = "internal"
url = "https://registry.example.com/python/simple"
default = true
Flipping from explicit to default is a real re-resolve, so I did it as a separate change after the URL swap, where lockfile churn was expected and reviewable on its own. To confirm a project had fully moved over, I grepped the lockfile:
grep -c 'pypi.org/simple' uv.lock # want 0
grep -c 'files.pythonhosted.org' uv.lock # want 0