# How to Build Byte-Identical Python Wheels


A {{< term "wheel" >}} is a ZIP archive, and ZIP entries record a modification time. Build the same source twice and you usually get two files with different sha256 checksums, which means nobody can rebuild your release and confirm it matches what you uploaded to {{< term "pypi" "PyPI" >}}.

This guide pins the three inputs that move: timestamps, file permissions, and the {{< term "build-backend" >}} version.

## Prerequisites

- [uv](https://pydevtools.com/handbook/reference/uv.md) installed
- A project with a `[build-system]` table in `pyproject.toml`
- A git repository, since the checks build from two separate checkouts

## Prove your build is not reproducible yet

Rebuilding in the same directory is not a valid test. The source files keep their original mtimes between runs, so a backend that copies those mtimes into the archive looks stable when it is not. flit-core passes that test and still produces a different wheel from a fresh clone.

Build from two checkouts with different file timestamps instead. Run this from the root of your project:

```bash
rm -rf /tmp/build-a /tmp/build-b
git clone --quiet . /tmp/build-a
git clone --quiet . /tmp/build-b
find /tmp/build-b -type f -exec touch -t 202601011200 {} +
(cd /tmp/build-a && uv build --wheel)
(cd /tmp/build-b && uv build --wheel)
shasum -a 256 /tmp/build-a/dist/*.whl /tmp/build-b/dist/*.whl
```
```bash
rm -rf /tmp/build-a /tmp/build-b
git clone --quiet . /tmp/build-a
git clone --quiet . /tmp/build-b
find /tmp/build-b -type f -exec touch -t 202601011200 {} +
(cd /tmp/build-a && uv build --wheel)
(cd /tmp/build-b && uv build --wheel)
sha256sum /tmp/build-a/dist/*.whl /tmp/build-b/dist/*.whl
```
```powershell
Remove-Item -Recurse -Force $env:TEMP\build-a, $env:TEMP\build-b -ErrorAction Ignore
git clone --quiet . $env:TEMP\build-a
git clone --quiet . $env:TEMP\build-b
Get-ChildItem -Recurse -File $env:TEMP\build-b | ForEach-Object { $_.LastWriteTime = '2026-01-01 12:00' }
Push-Location $env:TEMP\build-a; uv build --wheel; Pop-Location
Push-Location $env:TEMP\build-b; uv build --wheel; Pop-Location
Get-FileHash -Algorithm SHA256 $env:TEMP\build-a\dist\*.whl, $env:TEMP\build-b\dist\*.whl
```
`uv build --wheel` writes to `dist/` inside each checkout, so the last line compares one wheel against the other. For a flit-core project the two lines disagree:

```console
$ shasum -a 256 /tmp/build-a/dist/*.whl /tmp/build-b/dist/*.whl
df6e1d1d2061d90d2b9d318d81d4740e0920dbab5a6a5cc1e84346af4a26af5c  /tmp/build-a/dist/demo-0.1.0-py2.py3-none-any.whl
0cae859d27037994d07e3e985b8fa6bd61cc4d381df98a442cad40dcabe001dc  /tmp/build-b/dist/demo-0.1.0-py2.py3-none-any.whl
```

Two different checksums mean the remaining steps apply to your project. Two identical checksums mean your backend already pins its timestamps, and you only need the backend-version step.

## Look up what your backend already guarantees

Each row was measured by building the same package from two checkouts with different file mtimes:

| Build backend | Two checkouts, no `SOURCE_DATE_EPOCH` | Honors `SOURCE_DATE_EPOCH` | Output changes with `umask` |
| --- | --- | --- | --- |
| `uv_build` | Identical | No, pins every entry to 1980-01-01 | No |
| `hatchling` ([Hatch](https://pydevtools.com/handbook/reference/hatch.md)) | Identical | Yes | No |
| `flit_core` ([Flit](https://pydevtools.com/handbook/reference/flit.md)) | Differs | Yes | No |
| [`setuptools`](https://pydevtools.com/handbook/reference/setuptools.md) | Differs | Yes | Yes |

hatchling stamps every entry with 2020-02-02 unless you override it, which is why it survives a fresh checkout untouched.

## Pin every timestamp with SOURCE_DATE_EPOCH

`SOURCE_DATE_EPOCH` is a Unix timestamp that build tools substitute for the current time. Set it to the timestamp of the commit you are building, so the value is stable per release and the archive dates still mean something:

```bash
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
uv build --wheel
```
```powershell
$env:SOURCE_DATE_EPOCH = git log -1 --pretty=%ct
uv build --wheel
```
Setting it to `0` also works, but every entry lands on 1980-01-01: the ZIP format cannot store dates before then, and backends clamp to that floor rather than failing.

## Fix the umask when you build with setuptools

setuptools copies the build user's file permission bits into the archive, so the umask of the shell that ran the build changes the output. Building one project under umasks `022`, `002`, and `077` with `SOURCE_DATE_EPOCH` held constant produced three different checksums (`bfbcb502...`, `6d51e878...`, and `b434f902...`).

Set it explicitly in whatever runs the build:

```bash
umask 022
uv build --wheel
```

`022` is the conventional Unix default; set it explicitly instead of relying on whatever the host happens to use. The same three-umask test against hatchling, flit-core, and `uv_build` returned one checksum each time, so those backends need no umask step. Windows has no umask, and its file permission model does not reach the archive.

## Pin the build backend version

Each backend writes its own version into `dist-info/WHEEL` as a `Generator:` line, so a rebuild on a machine that resolved a newer backend produces a different checksum no matter how the timestamps are set. Pin exact versions in `pyproject.toml`:

```toml {filename="pyproject.toml"}
[build-system]
requires = ["hatchling==1.32.0"]
build-backend = "hatchling.build"
```

Pinning here constrains only the isolated environment uv creates to run the build. It does not affect what your package requires at install time.

## Verify the rebuild from two clean checkouts

Re-run the two-checkout check with every input pinned, by putting these two lines ahead of it:

```bash
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
umask 022
```

The comparison should now report one checksum twice:

```console
$ shasum -a 256 /tmp/build-a/dist/*.whl /tmp/build-b/dist/*.whl
e8848510e836a0e9a17fce00af00b797178913c4f9ae22d693e4b001125a0d49  /tmp/build-a/dist/demo-0.1.0-py3-none-any.whl
e8848510e836a0e9a17fce00af00b797178913c4f9ae22d693e4b001125a0d49  /tmp/build-b/dist/demo-0.1.0-py3-none-any.whl
```

Your digest will differ, because it depends on your source and on the commit timestamp feeding `SOURCE_DATE_EPOCH`. The two lines matching is the result to check.

With all three inputs pinned, the checksum also holds across operating systems: the same package built on macOS arm64 under Python 3.13 and on Debian x86_64 under Python 3.12 produced identical wheels for all four backends.

Publishing that checksum alongside a release lets anyone re-derive the artifact from source, and a [digital attestation](https://pydevtools.com/handbook/how-to/how-to-publish-python-packages-with-digital-attestations.md) proves which workflow uploaded it. Byte-identical rebuilds do not detect code injected during the build itself, which reaches the artifact before any checksum is taken; see [What Is a Python Supply Chain Attack?](https://pydevtools.com/handbook/explanation/what-is-a-python-supply-chain-attack.md) for the defenses that cover that stage.
