A build artifact can contain exactly the same application bytes and still produce a different checksum every time it is built.
ZIP timestamps are one common reason.
That matters when checksums are used for release verification, artifact caching, provenance, binary transparency, or simply deciding whether a build changed. If irrelevant metadata changes on every run, byte-for-byte comparison stops being useful.
Python 3.14 makes one important part of this easier: zipfile.ZipFile.writestr() now respects the SOURCE_DATE_EPOCH environment variable. When it is set, string-named entries written with writestr() can use the supplied epoch instead of the current time.
That is useful, but it is not a magic “make this ZIP reproducible” switch. A deterministic archive also depends on entry order, input bytes, filenames, compression settings, permissions, explicit ZipInfo metadata, and the exact path used to construct the archive.
This article builds that boundary carefully.
Why timestamps break byte-for-byte builds
Consider a tiny archive built from generated content:
from zipfile import ZipFile
def build_archive(path: str) -> None:
with ZipFile(path, "w") as archive:
archive.writestr("version.txt", "1.4.0\n")
archive.writestr("config.json", '{"debug": false}\n')The payload can remain unchanged between runs. Historically, however, passing a filename string to writestr() caused the entry timestamp to be based on the current date and time.
Run the build later and metadata can differ. The resulting ZIP bytes can therefore differ too.
This is exactly the kind of nondeterminism reproducible-build conventions try to eliminate.
Set SOURCE_DATE_EPOCH at the build boundary
SOURCE_DATE_EPOCH is conventionally an integer Unix timestamp chosen by the build system.
For example, a shell-based build can set it before invoking Python:
SOURCE_DATE_EPOCH=1767225600 python build_release.pyThe Python code does not need to convert that value before calling writestr():
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
def build_release(output: Path) -> None:
with ZipFile(output, "w", compression=ZIP_DEFLATED) as archive:
archive.writestr("app/version.txt", "1.4.0\n")
archive.writestr("app/settings.json", '{"debug": false}\n')On Python 3.14, writestr() respects SOURCE_DATE_EPOCH when it is responsible for choosing the entry modification time.
The important design choice is that the timestamp comes from the build environment rather than wall-clock time.
A useful epoch is normally derived from an immutable source event, such as the commit being built, rather than from time.time() during the build. Replacing one changing clock value with another changing clock value does not improve reproducibility.
Reproducibility is stronger than equal extracted files
Two archives can extract to identical files while still having different bytes.
A ZIP contains more than payload data. Entries have metadata, local headers, central-directory records, compression output, ordering, and attributes.
So define the requirement precisely.
If the requirement is only:
extract(build_a.zip) == extract(build_b.zip)then timestamp differences may be harmless.
If the requirement is:
sha256(build_a.zip) == sha256(build_b.zip)then every byte that participates in the archive matters.
Reproducible-build pipelines usually care about the second property.
Keep entry order deterministic
ZIP preserves member order. If input discovery produces a different order, the archive can differ even when all files are identical.
Filesystem iteration order should not become an accidental build input.
Sort explicitly:
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
def add_tree(archive: ZipFile, root: Path) -> None:
files = sorted(
path
for path in root.rglob("*")
if path.is_file()
)
for path in files:
arcname = path.relative_to(root).as_posix()
archive.writestr(arcname, path.read_bytes())
def build_release(root: Path, output: Path) -> None:
with ZipFile(output, "w", compression=ZIP_DEFLATED) as archive:
add_tree(archive, root)Now discovery order is normalized before entries are written.
Sorting by archive name rather than by incidental filesystem metadata also makes the rule easy to explain and test.
Normalize archive names
Absolute paths make poor archive identities. Build roots differ between developer machines, CI workers, and temporary directories.
Prefer names relative to a declared root:
arcname = path.relative_to(root).as_posix()Using POSIX-style separators gives the archive a stable naming convention independent of the host path representation.
Also decide intentionally whether directory entries are needed. An archive containing only files can extract correctly without explicit directory members in many workflows, while another producer may include them. Either policy can be valid; mixing policies is not deterministic.
Generated bytes must also be stable
SOURCE_DATE_EPOCH cannot repair nondeterministic payloads.
This JSON generation is risky if the source mapping or formatting policy is uncontrolled:
archive.writestr("manifest.json", make_manifest())Instead, define a canonical representation appropriate for the format:
import json
def encode_manifest(data: dict[str, object]) -> bytes:
text = json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
return (text + "\n").encode("utf-8")For other generated files, watch for timestamps, random identifiers, hostnames, temporary paths, locale-dependent formatting, and unordered collections.
A deterministic container cannot make a nondeterministic payload deterministic.
Pin the compression policy
Compression is another build input.
Choose the method explicitly:
with ZipFile(
output,
"w",
compression=ZIP_DEFLATED,
compresslevel=9,
) as archive:
...This prevents an accidental constructor-default change in your own code from silently changing output.
It does not mean compressed bytes are guaranteed to be identical across every possible Python, compression-library, and platform combination forever. If cross-toolchain byte identity is a hard requirement, pin and test the build toolchain as part of the reproducibility contract.
That distinction is important: deterministic inputs are necessary, but the implementation producing the compressed stream is also part of the build environment.
Understand the ZipInfo boundary
writestr() accepts either an archive-name string or a ZipInfo object.
Those are not interchangeable from a metadata-policy perspective.
A ZipInfo object lets the caller supply metadata directly:
from zipfile import ZipInfo
info = ZipInfo("app/data.txt", date_time=(2025, 1, 1, 0, 0, 0))
archive.writestr(info, b"payload\n")Once your code constructs metadata explicitly, your code owns that policy. Do not assume an environment-level default will override metadata you deliberately supplied.
Python 3.14 also adds ZipInfo._for_archive(archive), which resolves date, compression, and external-attribute defaults in the same general context used by writestr() and returns the object for chaining.
That is useful when constructing ZipInfo instances programmatically, but explicit metadata still deserves explicit tests. Reproducibility should not depend on guessing which defaults win.
File permissions can change an archive
A release ZIP may need executable bits for scripts or stable ordinary-file modes.
If permissions matter, normalize them instead of inheriting arbitrary checkout state.
For a controlled ZipInfo workflow, define a policy and test the resulting external_attr values. For example, a project may require all regular data files to be 0644 and selected launchers to be 0755.
Do not blindly copy every source filesystem attribute into a portable artifact. CI umasks, checkout tools, and host operating systems can differ.
At the same time, do not erase meaningful executable metadata if consumers rely on it.
The right answer is a declared archive policy, not “whatever this machine reported.”
SOURCE_DATE_EPOCH does not mean business time
A reproducibility timestamp is build metadata. It should not be reused as application-domain truth merely because it is convenient.
Suppose the archive contains a schema with an actual publication time:
{
"published_at": "2026-09-09T08:00:00Z"
}That value may be semantically meaningful and should remain the real publication time.
The ZIP member modification timestamp serves a different purpose. Normalizing it does not require falsifying timestamps inside application data.
Keep build metadata and domain data separate.
Beware the ZIP timestamp range
The central-directory timestamp exposed by ZipInfo.date_time has ZIP-format constraints and represents local time semantics rather than a full arbitrary Unix timestamp field.
ZIP’s traditional timestamp representation cannot represent dates before 1980. This matters if a project chooses an unusually early SOURCE_DATE_EPOCH.
Do not choose 0 reflexively just because the Unix epoch looks neutral. Test the epoch policy against the archive format and the Python versions you support.
A source-derived date within the representable range is usually a better choice.
Compatibility with Python before 3.14
The writestr() behavior described here changed in Python 3.14.
If a build must run on Python 3.13 or earlier, setting SOURCE_DATE_EPOCH alone is not a portable way to obtain the same result.
There are two clean strategies.
The first is to require Python 3.14 for the reproducible build path:
import sys
if sys.version_info < (3, 14):
raise RuntimeError("reproducible ZIP builds require Python 3.14+")The second is to implement an explicit metadata-normalization layer using ZipInfo so older runtimes receive the timestamp policy directly.
Avoid a compatibility shim that silently behaves differently across runtimes. A build system should make its reproducibility guarantees observable.
Test bytes, not intentions
A good reproducibility test builds the artifact twice under deliberately different irrelevant conditions and compares the resulting bytes.
import hashlib
from pathlib import Path
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def test_release_is_reproducible(tmp_path, monkeypatch):
monkeypatch.setenv("SOURCE_DATE_EPOCH", "1767225600")
first = tmp_path / "first.zip"
second = tmp_path / "second.zip"
build_release(first)
build_release(second)
assert sha256(first) == sha256(second)For stronger coverage, vary conditions that should not matter: source checkout directory, process working directory, file discovery order, timezone where relevant, and umask if your archive policy normalizes permissions.
Then inspect metadata as a separate assertion:
from zipfile import ZipFile
def test_archive_member_order(tmp_path, monkeypatch):
monkeypatch.setenv("SOURCE_DATE_EPOCH", "1767225600")
output = tmp_path / "release.zip"
build_release(output)
with ZipFile(output) as archive:
names = archive.namelist()
assert names == sorted(names)A checksum test catches byte differences. Metadata-focused tests explain why a difference happened.
Do not mutate the process environment casually in threaded builds
It can be tempting to write this inside a library function:
import os
os.environ["SOURCE_DATE_EPOCH"] = "1767225600"That changes process-global state.
For a command-line build tool, prefer having the caller or top-level build orchestration establish the environment before worker threads and build steps begin.
If the timestamp is application configuration rather than inherited build configuration, an explicit ZipInfo policy can be easier to reason about than temporarily mutating os.environ around calls.
The environment variable is a build interface, not an excuse for hidden global-state changes deep in a library.
A practical deterministic builder
Putting the pieces together, a small builder can make its policy visible:
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
def build_zip(source: Path, output: Path) -> None:
files = sorted(
(p for p in source.rglob("*") if p.is_file()),
key=lambda p: p.relative_to(source).as_posix(),
)
with ZipFile(
output,
"w",
compression=ZIP_DEFLATED,
compresslevel=9,
) as archive:
for path in files:
name = path.relative_to(source).as_posix()
archive.writestr(name, path.read_bytes())Its reproducibility contract is now understandable:
- the build environment supplies a stable
SOURCE_DATE_EPOCH; - archive names are relative and normalized;
- members are sorted;
- payload bytes come directly from controlled inputs;
- compression method and level are explicit.
A production builder may additionally normalize permissions, generated manifests, comments, and any project-specific metadata.
Treat reproducibility as a supply-chain property
Reproducible archives are useful beyond aesthetically pleasing checksums.
Stable artifacts make cache keys more meaningful. They make it easier to compare independently produced releases. They reduce noise in provenance systems. They can also reveal undeclared build inputs: if two supposedly identical builds differ, something outside the declared source and toolchain influenced the result.
But reproducibility is not authenticity.
An attacker can reproducibly build malicious input. Continue to use signatures, trusted provenance, access controls, and review where those properties matter.
Likewise, a matching checksum proves byte equality, not that the bytes are safe.
Choose a narrow contract and enforce it
Python 3.14’s SOURCE_DATE_EPOCH support in ZipFile.writestr() removes a common source of accidental timestamp nondeterminism. It is most valuable when treated as one piece of a larger build contract.
For byte-for-byte ZIP reproducibility, control all the inputs that affect serialization: timestamps, names, member order, generated content, permissions, compression policy, and toolchain versions where necessary.
Then test the property you actually care about by building twice and comparing the bytes.
That turns reproducibility from a hopeful convention into an executable guarantee.