uv 0.13.0 replaced the tar implementation underneath every archive uv reads, and the release notes say so in one line: "uv may now reject archives containing hard links or unsupported tar extensions that previous versions accepted." A dependency that resolved, downloaded and built on 0.12.24 can stop installing on 0.13.0 with the same lockfile and the same pinned hash, and the archive is not corrupt. By the end of this you will have a script that reads uv.lock, pulls every source distribution it pins and names the ones carrying a hard-link member, a local reproduction that needs no network, and three fixes ordered by how long you can leave them in place.

The release note announcing the change enumerates nothing: no affected packages, no error-string reference, and the opt-out environment variable landed in the same release. The mechanism has to be read out of the backend crate, which is most of the work below. uv's archive layer has produced this class of surprise before, as in the 0.12.14 symlinked-directory rejection.

uv sync on 0.13.0fails for one packageDoes uv.lock pinan sdist for it?Wheel pathlook elsewhere, not tar-codecScan the sdist memberswith tarfileAny islnk memberor odd member type?Size-bounded pax or GNUextension headerPick a fix:wheel, repack, or legacy backend"no""yes""no""yes"

Prerequisites

  • uv 0.13.0 on the machine under test (uv --version). A standalone-installer uv moves with uv self update; a pip-installed or Homebrew uv has to be upgraded through that package manager.
  • Python 3.11 or newer for the audit script. It uses tomllib, which entered the standard library in 3.11.
  • A project with a committed uv.lock.
  • GNU tar if you intend to repack an archive (tar --version), for the --hard-dereference flag.
  • The ability to run with a cold cache, because that is the only condition under which the new policy executes.

Hold onto one fact before you blame a package author. The PyPA source distribution format spec allows links in an sdist. It forbids only "Links (symbolic or hard) pointing outside the destination directory," and it tells tools they "MAY unpack links (symbolic or hard) as regular files, using content from the archive." A stricter client is weak evidence about the artifact, which was also the situation when Terraform 1.16.3 started rejecting binary/octet-stream downloads that were perfectly good zips.

Step-by-step

1. Confirm the backend changed under you

uv --version
uv cache dir

PR #22094, "Use tar-codec by default", removed the crate's preview flag and routed extraction, uv_build sdist creation and uv publish metadata reads through it. The crate is tar-codec, part of astral-sh/astral-codec, whose README still carries the line: "This repository is in a very early state of development and is not considered ready for production use." That is the component now opening every tarball uv touches.

2. List every sdist your lockfile pins

# scan_sdists.py
import tomllib, sys

lock = tomllib.load(open("uv.lock", "rb"))
for pkg in lock.get("package", []):
    sdist = pkg.get("sdist")
    if sdist and "url" in sdist:
        print(pkg["name"], pkg["version"], sdist["url"], sep="\t")
python3 scan_sdists.py | tee sdists.tsv | wc -l

Each [[package]] table in uv.lock carries an optional sdist = { url, hash, size }. Packages resolved to a wheel have no sdist key and cannot hit this failure, so the first pass usually cuts the surface down to a handful of rows.

3. Classify the members of each archive

# audit_tars.py
import io, sys, tarfile, urllib.request

OK = {tarfile.REGTYPE, tarfile.AREGTYPE, tarfile.DIRTYPE, tarfile.SYMTYPE}

for line in open(sys.argv[1]):
    name, version, url = line.rstrip("\n").split("\t")
    raw = urllib.request.urlopen(url).read()
    with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as tf:
        for m in tf.getmembers():
            if m.islnk():
                print(f"HARDLINK\t{name}=={version}\t{m.name} -> {m.linkname}")
            elif m.type not in OK:
                print(f"TYPE {m.type!r}\t{name}=={version}\t{m.name}")
            elif m.pax_headers:
                big = {k: len(v) for k, v in m.pax_headers.items() if len(v) > 1024}
                if big:
                    print(f"PAXSIZE\t{name}=={version}\t{m.name}\t{big}")
python3 audit_tars.py sdists.tsv

TarInfo.islnk() is true only for a hard-link member (type 1), which is the case ExtractPolicyViolation::HardLink describes: "A hard-link member appeared when links are forbidden." The pax_headers check exists because the tar-codec decoder reads pax and GNU streams but bounds them with DEFAULT_MAX_PAX_EXTENSION_SIZE, DEFAULT_MAX_GNU_EXTENSION_SIZE and DEFAULT_MAX_GLOBAL_PAX_EXTENSIONS_SIZE, so an archive with an enormous extension header is turned away on size, not on member type. Reading with mode="r:gz" is the spec's own requirement for an sdist, which makes this audit the cheapest conformance check available.

Hardlinked sdists are not hypothetical. setup.py sdist hard-links files into its staging directory, and astropy 3.1 published a tarball whose astropy/CITATION was a hard link to the top-level CITATION. Issue #8299 records the symptom on a filesystem that refused it: tar: astropy-3.1/astropy/CITATION: Cannot hard link to 'astropy-3.1/CITATION': Operation not permitted.

4. Reproduce the rejection offline, in ten lines

# make_bad_sdist.py
import tarfile, io, os

os.makedirs("dist", exist_ok=True)
with tarfile.open("dist/demo-0.1.0.tar.gz", "w:gz", format=tarfile.PAX_FORMAT) as tf:
    def add(name, body):
        ti = tarfile.TarInfo(f"demo-0.1.0/{name}")
        ti.size = len(body)
        tf.addfile(ti, io.BytesIO(body))
    add("PKG-INFO", b"Metadata-Version: 2.1\nName: demo\nVersion: 0.1.0\n")
    add("pyproject.toml", b'[project]\nname="demo"\nversion="0.1.0"\n')
    link = tarfile.TarInfo("demo-0.1.0/CITATION")
    link.type, link.linkname, link.size = tarfile.LNKTYPE, "demo-0.1.0/PKG-INFO", 0
    tf.addfile(link)
python3 make_bad_sdist.py
uv pip install --no-cache ./dist/demo-0.1.0.tar.gz -v

Extraction happens before any build backend runs, so this thin archive fails at the archive layer and never reaches a build. The bottom of the Caused by: chain is the line that comes from the extract policy, and the one to paste into a bug report. Then confirm the diagnosis in a single stroke:

UV_LEGACY_TAR_BACKEND=1 uv pip install --no-cache ./dist/demo-0.1.0.tar.gz

If the second command succeeds, the archive is the variable and your resolution is fine.

5. Choose the fix you can live with

# A. take a wheel for that package, never its sdist
uv pip install --only-binary :all: <package>
# A, durably, in pyproject.toml
[tool.uv]
no-build-package = ["<package>"]
# B. repack the sdist with links resolved to real files
tar -xzf demo-0.1.0.tar.gz
tar --hard-dereference -czf demo-0.1.0-repacked.tar.gz demo-0.1.0/
# C. scope the compatibility fallback to the one job that needs it
UV_LEGACY_TAR_BACKEND=1 uv sync --locked

no-build-package defaults to [] and is documented in the uv settings reference. Option C is the fallback uv shipped for this case; the environment variable reference describes UV_LEGACY_TAR_BACKEND as "Use the legacy backend to read and write tar archives," added in 0.13.0. Put a dated comment beside it in your Dockerfile and give it a review date, because the variable has no stated lifetime.

6. Make the audit a gate, not a memory

python3 scan_sdists.py > sdists.tsv
python3 audit_tars.py sdists.tsv > findings.txt
test ! -s findings.txt || { cat findings.txt; exit 1; }

Run that on every lockfile change. A new transitive sdist with a hard-link member then breaks a cheap job before it reaches a deploy.

Verify it works

uv cache clean demo
unset UV_LEGACY_TAR_BACKEND
uv sync --locked --refresh
python3 audit_tars.py sdists.tsv && echo "clean"

Success looks like uv sync completing with the variable unset and the audit printing nothing before clean. The cache clean matters more than it looks: without it, uv can satisfy the install from an entry the old backend unpacked, and the check proves nothing.

Common pitfalls

Passing locally, failing in CI. The 0.13.0 notes say uv "may download or rebuild dependencies after upgrading, because some cached entries from earlier versions cannot be reused." A warm developer cache skips extraction, so the first machine to meet the new policy is the one with no cache at all. Reproduce with --no-cache or --refresh. That asymmetry is the same reason a package manager bump can go green in CI and fail days later, as the npm 12 rollout did.

Setting the fallback globally. UV_LEGACY_TAR_BACKEND=1 switches the writer too, and the release notes warn that "Source distributions created by uv_build can also have different archive bytes and hashes." A build image carrying that variable publishes different artifact bytes than one without it, turning a read-side workaround into a reproducibility question.

Editing hashes in uv.lock. The hash is not failing. The archive's bytes are unchanged and still match. Rewriting lock hashes to chase an extraction error removes a control and fixes nothing.

Forgetting the backend is new code. The crate README calls itself very early and not production ready, so a rejection can be the backend's bug and not a policy decision. Keep the possibility open the way a pnpm 12.6 client bug that reported invalid peer certificate: UnknownIssuer turned out to have nothing wrong with the certificate. Capture the exact policy string and the failing URL, and open an issue.

Colliding with the other 0.13.0 default. The same release moved the default stable Python from 3.14 to 3.15 (PR #22400). Reaching for --only-binary :all: on a package with no 3.15 wheel changes the failure's shape without removing it. Pin the interpreter in .python-version while you work and keep the two changes separate.

FAQ

Is a hard link inside an sdist a malformed package? No. The PyPA spec permits links and forbids only links pointing outside the destination directory, so an archive uv 0.13.0 refuses can still be conforming.

Does UV_LEGACY_TAR_BACKEND=1 change anything besides reading archives? Yes. It switches the writer too, and the 0.13.0 notes state that sdists created by uv_build can have different archive bytes and hashes between the backends.

Why did the install work on my laptop and fail in CI? The local run never extracted anything. Cached entries from earlier versions that cannot be reused are what force the new path to run.

Is pip affected? No. pip extracts with the standard library tarfile module, which unpacks hard-link members as regular files.

Wrap-up

You have a reproducible local failure, a lockfile-wide audit naming the offending member and its link target, and three fixes ordered by durability. Run the audit against every service you own before anyone bumps uv past 0.12.24, and record the date wherever you set UV_LEGACY_TAR_BACKEND, because it is a transition aid with no announced end.