Skip to content

How to Build Byte-Identical Python Wheels

A wheelA prebuilt Python package file (.whl) that installs without compiling anything. The standard distribution format for Python packages. Learn more → 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 PyPIThe Python Package Index, the public repository where Python packages are published and downloaded from. "pip install requests" fetches requests from PyPI. Learn more → .

This guide pins the three inputs that move: timestamps, file permissions, and the build backendThe tool that does the actual work of turning your source code into an installable package. Examples include hatchling, setuptools, and uv_build. version.

Prerequisites

  • uv 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:

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

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) Identical Yes No
flit_core (Flit) Differs Yes No
setuptools 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:

export 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:

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:

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:

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

The comparison should now report one checksum twice:

$ 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? for the defenses that cover that stage.

Learn More

Last updated on