When does Python rebuild a .pyc? Timestamp vs hash-based invalidation

By default CPython trusts a .pyc if the source's mtime and size match. When that misses edits, how checked-hash and unchecked-hash pycs differ, and how to use compileall (Python 3.14.8 docs).

DevelopmentPythonCPythonBytecode

You edit a Python file, run it again, and the old behaviour is still there. If deleting __pycache__ makes the problem go away, that tells you a stale cache was involved, but not why it was used. CPython has three ways of deciding whether a .pyc can be trusted, and which one applies depends on how the file was written.

A .pyc is an import cache

When CPython has to compile a source module during import, it can save the bytecode in a .pyc file so a later load can reuse it. Since PEP 3147 in Python 3.2, that file lives in a __pycache__ directory next to the source, and its name carries an interpreter tag. On CPython 3.14 it looks like this:

pkg/
├── settings.py
└── __pycache__/
    └── settings.cpython-314.pyc

The tag comes from sys.implementation.cache_tag. The docs describe it as, by convention, the implementation name plus version (like 'cpython-33'), though an implementation may use another value. The Python 3.2 “What’s New” explains why the tag exists: different interpreters used to fight over a single mymodule.pyc, each recompiling and overwriting it. So don’t treat a .pyc as something you can carry to a different Python version or implementation.

The standard library will tell you where the cache for a given source file goes:

import importlib.util
print(importlib.util.cache_from_source("pkg/settings.py"))

With -B or the PYTHONDONTWRITEBYTECODE environment variable, Python doesn’t write new .pyc files on import. With PYTHONPYCACHEPREFIX (or -X pycache_prefix=PATH, since 3.8) it writes them to a mirror tree under that path instead of __pycache__.

Three invalidation modes

PEP 552, in Python 3.7, extended the .pyc format so the source hash can be used for invalidation. Before that, timestamps were the only option. The mode a .pyc was built with is recorded in its header. The table below is the current behaviour as described in the Python 3.14.8 docs.

Mode Stored in the .pyc What happens on import
timestamp (default) Source’s last-modified time and size Compared with the current source’s mtime and size
checked-hash Hash of the source contents Source is hashed and compared; on mismatch the .pyc is regenerated
unchecked-hash Hash of the source contents Assumed valid if it exists; never compared with the source

The hash in the header is the value importlib.util.source_hash() returns for the source. According to the 3.7 “What’s New”, CPython still uses timestamp invalidation by default and does not generate hash-based .pyc files at import time. You create them with py_compile or compileall.

What the timestamp check can miss

The timestamp mode never reads the source contents for comparison. If the recorded mtime and size match the current file, the cache is used even if the contents changed.

Say settings.py goes from VALUE = 1 to VALUE = 2. The size stays the same. The mtime can also end up matching the old one in situations like these:

  • The filesystem’s timestamp resolution is coarse and both edits land in the same tick. The 3.7 “What’s New” says as much: with coarse timestamps Python can miss source updates.
  • A deploy or archive tool copies files with their original mtimes preserved, and the target already has a .pyc built from different contents with the same mtime and size.
  • The build pins every file’s mtime to a fixed value.

When these conditions line up, the import silently runs the old bytecode. I did not run these scenarios for this article; they follow from the comparison rule in the docs.

The opposite case is harmless but wasteful: if only the mtime changes and the contents don’t, Python recompiles. In environments that touch every file on each build, such as container images, the cache gets reused less.

Checked-hash and unchecked-hash

When Python loads a module from a checked-hash .pyc, it reads and hashes the source file and compares the result with the hash in the header. A module already in sys.modules is returned without this check, so it happens when the module is loaded, not every time an import statement runs. On a mismatch it recompiles and writes a new checked-hash .pyc. Because it doesn’t depend on mtime, it catches the changes described above. The cost is reading and hashing the whole source file.

An unchecked-hash .pyc is assumed valid as long as it exists. If you edit the source, the old bytecode keeps running until something rebuilds the .pyc. The py_compile docs say this mode is useful when a system outside Python, such as a build system, keeps the .pyc files up to date. It is a poor fit for a tree you edit by hand.

You can change how hash-based pycs are validated with an interpreter option:

python --check-hash-based-pycs always app.py
Value Behaviour
default Checked pycs are validated, unchecked ones are not
always Both checked and unchecked pycs are validated against the source
never Hash-based pycs are not validated against the source

The option has no effect on timestamp-based .pyc files. If you suspect unchecked-hash pycs in a deployed environment, running once with always and seeing whether the behaviour changes helps tell whether the cache is the problem.

Compiling with a chosen mode

compileall walks directories recursively.

python -m compileall --invalidation-mode checked-hash src/
python -m compileall -f -q --invalidation-mode unchecked-hash build/lib/
  • --invalidation-mode takes timestamp, checked-hash or unchecked-hash.
  • -f recompiles even if timestamps are up to date.
  • -q stops it listing the files it compiles. Errors are still printed; -qq suppresses everything.

For a single file, use py_compile:

import py_compile

py_compile.compile(
    "src/settings.py",
    invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH,
)

The default depends on the SOURCE_DATE_EPOCH environment variable. For both py_compile.compile() and compileall, the default is checked-hash when it is set and timestamp when it isn’t. In 3.7, py_compile let the variable override an explicit mode and force checked-hash; since 3.7.2 it only sets the default. If your .pyc files come out in a mode you didn’t expect, check whether the build environment sets this variable.

Deleting pycache is a diagnostic step

If deleting __pycache__ makes the problem go away, you’ve confirmed that a stale cache was involved, and nothing more. To stop it happening again, find the cause:

  • Does the deploy tool preserve or pin mtimes?
  • Which invalidation mode were the .pyc files in your build artifact built with?
  • Are you shipping unchecked-hash pycs while some path replaces source files on their own?
  • Is SOURCE_DATE_EPOCH set in the build environment?

Which mode to use

  • Local development and ordinary runs: the default timestamp mode is fine.
  • When mtimes can’t be trusted or you need reproducible builds: checked-hash. The 3.7 “What’s New” notes that timestamps in the cache are a problem for build reproducibility and content-based build systems.
  • When source and .pyc are always replaced together by the same build and nobody edits source after deploy: unchecked-hash. If you might patch source files separately, avoid it.

Hash-based pycs are only a way of deciding whether the cache is current. They are not code signing and say nothing about whether a file has been tampered with.

The behaviour described here was checked against the Python 3.14.8 documentation on October 9, 2026.

References

Back to the blog