# 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. 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 three umasks with `SOURCE_DATE_EPOCH` held constant produced three different checksums:

| `umask` at build time | Resulting wheel sha256 prefix |
| --- | --- |
| `022` | `bfbcb502aadd13fd...` |
| `002` | `6d51e8784b1535df...` |
| `077` | `b434f90231f2bdff...` |

Set it explicitly in whatever runs the build:

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

`022` is the conventional build umask used by GitHub Actions Linux runners; set it explicitly instead of relying on the host default. 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. 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.

## Learn More

- [SOURCE_DATE_EPOCH](https://reproducible-builds.org/docs/source-date-epoch/) specifies the variable and how build tools consume it
- [Reproducible builds](https://flit.pypa.io/en/latest/reproducible.html) in the Flit docs covers the flit-core side in detail
- [build-and-inspect-python-package](https://github.com/hynek/build-and-inspect-python-package) is a GitHub Action that sets `SOURCE_DATE_EPOCH` from the last commit for any backend following [PEP 517](https://pydevtools.com/handbook/explanation/what-is-pep-517.md), the standard build-backend interface
- [Why pylock.toml Includes Digital Attestations](https://pydevtools.com/handbook/explanation/why-pylock-toml-includes-digital-attestations.md) explains what publisher identity proves that a checksum does not
