Your service had a latency spike right after a deploy. A teammate rules out the garbage collector: "We run Python 3.14, and 3.14 collects in small steps, so it cannot pause for long." Whether that is true depends on which 3.14 ran.
The 3.14 collector changed, then changed back
Python 3.14.0 to 3.14.4 shipped an incremental garbage collector. It split the work on long-lived objects into small steps, to make each pause shorter. After reports of much higher memory use in production, Python 3.14.5 went back to the generational collector from 3.13. That one sorts objects into three generations by how many collections they have survived, and checks the youngest most often. Python 3.15 uses the generational collector too.
Three gc calls behave differently in the two designs:
| Call | 3.14.0 to 3.14.4 | 3.12, 3.13, 3.14.5 and later |
|---|---|---|
gc.collect(1) |
Collects the young generation and one step of the old one | Collects the two youngest generations |
gc.get_objects(1) |
Returns nothing, because there is no generation 1 | Returns the objects in generation 1 |
gc.set_threshold(t0, t1, t2) |
Ignores t2 |
Uses t2 |
Even on 3.14.0 to 3.14.4, gc.collect() with no argument ran a full
collection. So a full pause was still possible on those versions.
Check your understanding A teammate says, "We run Python 3.14, so a long collector pause cannot explain the spike." What do you check? Show answer Hide answer
First find the exact version and build of the process that was running at
the time. Then look for explicit gc.collect() calls in your code and its
libraries, because even the incremental collector ran full collections
when asked. Finally, check whether collections happened during the spike.
Record what actually ran
requires-python = ">=3.14" in pyproject.toml says which versions the
project supports, not which one ran. A Docker tag such as
python:3.14-slim can point at a newer image next week. Have the running
process report itself at startup:
import gc
import json
import platform
import sys
import sysconfig
runtime: dict[str, object] = {
"implementation": platform.python_implementation(),
"version": sys.version,
"platform": platform.platform(),
"machine": platform.machine(),
"free_threaded_build": bool(sysconfig.get_config_var("Py_GIL_DISABLED")),
"gc_enabled": gc.isenabled(),
"gc_thresholds": list(gc.get_threshold()),
}
gil_probe = getattr(sys, "_is_gil_enabled", None)
runtime["gil_enabled"] = bool(gil_probe()) if callable(gil_probe) else None
print(json.dumps(runtime, sort_keys=True))
The script only reads settings, and it runs on Python 3.12 and later.
free_threaded_build says whether this is a free-threaded build, one
without the GIL. gil_enabled says whether the GIL is on right now,
because a free-threaded build can turn it back on. Python 3.12 has no way
to report that, so the value is null there.
Write this line to your startup log. Also record the application's revision, its dependency versions and the image digest, which the script cannot see. The thresholds it logs are the current settings, which your code or a library may already have changed.
Check your tuning after every upgrade
If your code calls gc.set_threshold, read the gc documentation for the
exact version you deploy. Doubling t2 did nothing on 3.14.0 to 3.14.4,
and it matters again from 3.14.5. Then compare the old and new versions
under the same load. Count the collections and time them, and watch
memory and request latency.
Pin the image, and keep updating it
A tag can move, but a digest such as python@sha256:... names exactly one
image. Record the digest you deployed. A newer patch release can fix bugs
you care about, so update the pin on a schedule and rerun your checks.
Keep the previous digest, so that you can roll back.
Going further
- On Python 3.15,
gc.get_stats()also reportsduration, the total time in seconds that each generation's collections took. - What's New in Python 3.14 records both the change and its reversal, and the gc documentation marks each call that changed.
- Docker's guidance on pinning base images explains why tags move.