Teams write requires-python = ">=3.14" in pyproject.toml, pin a base image to python:3.14-slim, and assume that patch releases within a minor version preserve behaviour. That assumption is correct almost all the time, which is exactly what makes the counterexample worth studying.
What happened
Python 3.14.0 shipped a new incremental garbage collector. The claim in the release notes was concrete and attractive: “maximum pause times are reduced by an order of magnitude or more for larger heaps”. It came with two API-visible consequences:
-
gc.collect(1)no longer performed a full collection of generation 1. It performed an increment — a bounded slice of work — which is a different operation with a different cost and a different effect. -
gc.set_threshold()‘s second parameter,threshold2, was ignored, because the new design did not have the generation it configured.
Python 3.14.5 reverted it. The stated reason was “a number of reports of significant memory pressure in production environments”. The 3.13-style generational collector came back, gc.collect(1) went back to meaning what it used to mean, and threshold2 was restored.
Python 3.15 also uses the generational collector. So the incremental GC was the behaviour of exactly five patch releases — 3.14.0 through 3.14.4 — and is now history.
💡Your service runs python:3.14-slim. A memory-pressure incident happened in April. Someone in the review says "3.14 has an incremental GC, so gen-2 pauses aren't the cause". What is wrong with that sentence, and what do you need to know to replace it?
click to reveal
Two things are wrong. First, “3.14 has an incremental GC” is not a well-formed statement: it was true for 3.14.0–3.14.4 and false from 3.14.5 onward. Second, and more importantly, the container tag 3.14-slim does not pin a patch version — it moves. The image that was pulled in April and the image that is pulled today are almost certainly different builds with different collectors.
To replace it you need the exact interpreter version that was running during the incident, which means sys.version (or python -VV) captured as a startup log line or a metric label, not inferred from the Dockerfile. If you do not already emit it, that is the actual action item from this incident.
The general principle: for anything that is a runtime characteristic rather than an API — GC strategy, allocator, JIT state, free-threading — the minor version is not a precise enough identifier. Only the full version is.
The transferable skill: source literacy
The lesson is not “the incremental GC was bad”. It is that the way most engineers learn about a version’s behaviour — a blog post, a conference talk, a summary thread, an LLM’s memory — is a snapshot of a moving target, and none of those sources get retracted when the thing they describe is reverted.
Three habits that survive this:
Read the versionchanged and versionadded directives in the docs, not the “What’s New” highlights. The What’s New page is written to sell a release; it lists what was added and rarely what was taken back. The library reference for a specific function carries .. versionchanged:: 3.14.5 markers, which are precise, dated, and attached to the API you are actually calling. When a claim matters, read the .rst source in the CPython repository — it is the only place where the full history of a directive is visible.
Read the patch-level changelog. Misc/NEWS.d in the CPython tree, or the changelog page for a specific point release. Behaviour reverts live there and almost nowhere else. “What’s New in 3.14” will not tell you that something in 3.14 was undone in 3.14.5.
Date every claim you carry. A performance or behaviour claim without a version and a date is not usable in an argument. “The GC pauses less on 3.14” is folklore. “3.14.0 through 3.14.4 used an incremental collector; 3.14.5 reverted it; we are on 3.14.7” is a fact you can act on.
💡What is the failure mode of the opposite over-correction — treating every patch release as potentially behaviour-changing and refusing to auto-update? click to reveal
You accumulate unpatched security fixes, which is a far more likely way to have a bad quarter than a GC revert. The patch stream is overwhelmingly bug and security fixes; the base rate of behaviour changes is very low, and this episode is notable because it is unusual.
The workable posture is not “pin everything forever” but “pin precisely, and know when you moved”. Pin the full version (python:3.14.7-slim, not 3.14-slim) so upgrades are a reviewed commit rather than a silent pull; emit the running version as a metric label so incidents can be correlated with it; and read the patch changelog as part of the bump, which for a typical release takes two minutes because there is nothing behavioural in it.
What you are buying is not safety from change. It is the ability to answer “what changed?” in an incident, which is the question that actually costs teams time.
The one API note worth keeping
If you have code that calls gc.set_threshold(a, b, c) or gc.collect(1), those calls were meaning-stable before 3.14.0, unstable across 3.14.0–3.14.4, and meaning-stable again afterwards. gc.get_threshold() returns (2000, 10, 10) by default on 3.13, on 3.14.5+, and on 3.15.
Tuning code that reads the current thresholds and adjusts them relatively — rather than hardcoding a tuple — survived the whole episode unchanged. That is not luck; it is the general reason to read configuration before writing it.