Python 3.14.5 Reverts the Generational Garbage Collector
Meta: Python 3.14.5 shipped May 10, 2026 with a critical garbage collector reversion back to the generational collector. Here’s why the incremental approach failed in production, what security fixes matter, and how to upgrade safely.
Slug: python-314-5-garbage-collector-reversion-guide
Your production servers are running Python 3.14.3. Memory creep has been steady — 50 MB a day, unexplained, growing even when request volume is flat. You profile it with tracemalloc. Nothing jumps out. You restart services and memory drops, only to start climbing again within hours.
Then you upgrade to 3.14.4 expecting bugfixes and stability improvements, and the memory spikes double overnight.
That’s the story that brought down the incremental garbage collector in Python 3.14.5 — released today, May 10, 2026. The CPython core team made an unusual decision: revert to the generational garbage collector from Python 3.13, essentially rolling back a feature that was introduced as a headline improvement in 3.14. If you’re running production Python, this is the guide you need to understand what happened, why it matters for your stack, and how to upgrade without disruption.
The Incremental Garbage Collector — An Ambitious Idea
Python 3.14 introduced the incremental garbage collector as the default for the cyclic GC — the part of Python’s memory management that tracks reference cycles between objects. This is separate from the gc.collect() function that collects objects unreachable from any root.
The idea behind the incremental collector was elegant and well-motivated. The cyclic GC in Python works by scanning all live objects for reference cycles. In a typical application with millions of objects, a full GC cycle can take tens or hundreds of milliseconds. For a web server handling requests in under 10 ms, that’s an unacceptable pause.
The incremental collector solves this by slicing the GC work into tiny increments — typically processing just 200 objects per time slice — and spreading the work across many allocation cycles. The theory was: no single allocation should cause a noticeable pause, because each only does a few GC operations before returning.
In controlled benchmarks and continuous integration environments, this worked beautifully. Pauses dropped to microsecond-scale. Latency distributions tightened. Everything looked great on paper.
What Went Wrong in Production
The problem emerged when real production workloads hit the incremental collector. Multiple teams reported significant memory pressure, documented in CPython Issue #142516. The symptoms were consistent across different application stacks:
- Memory creep: Steady, unexplained growth even at steady request rates
- Affected stacks: Applications using
ssl,requests,urllib3, andmsal(the Microsoft Authentication Library) were most impacted - The ssl package: Specifically,
ssl.SSLContext.load_verify_locations()had a reference leak that the generational GC happened to collect efficiently
The root cause was subtle. The incremental collector’s finer-grained cycle detection surface exposed reference leaks in ways the generational collector masked by coincidence. Here’s why:
The generational collector operates on a weak generational hypothesis — most objects die young. It checks young objects (generation 0) very frequently and old objects (generation 2) much less often. When a reference leak existed in ssl objects, the generational GC would collect them during a gen-0 collection cycle and the memory would be reclaimed. The leak was real but invisible because it happened fast enough to not matter.
The incremental collector, by processing objects in small slices, changed the timing. References that should have been collected in the same cycle were now spread across multiple slices. The reference leak in ssl.SSLContext objects accumulated because each increment processed only a subset of objects, and the leaked references survived across multiple increment cycles.
The fix required two parts. First, the ssl reference leak was patched in PR #143685. Second, the core team made the pragmatic decision to revert the default GC strategy back to generational, as formalized in PR #148687. The free-threaded GC implementation was kept unchanged because it serves a different purpose in GIL-disabled builds.
Understanding the Generational GC
Before upgrading, it’s worth understanding what you’re going back to. The generational GC divides objects into three generations based on how long they survive between collections.
The Generational Hypothesis
The algorithm relies on an empirical observation made in 1978 by John Walker and popularized by David Ungar and Randall Smith in their 1984 paper on the SELF language: most allocated objects are short-lived, while objects that survive several collection cycles tend to persist for the rest of the program’s lifetime. This observation — known as the weak generational hypothesis — holds true for the vast majority of Python workloads.
By dividing objects into generations and collecting them at different frequencies, the generational GC achieves two goals simultaneously: rapid reclamation of short-lived temporary objects (request handlers, parsed JSON, intermediate results) and infrequent checks of long-lived infrastructure objects (loaded modules, global caches, database connections). The result is that each collection cycle touches a small, manageable set of objects, keeping both memory overhead and CPU cost predictable.
The incremental collector, by contrast, processed every generation in equal small slices. This meant that every allocation cycle performed work proportional to the total number of live objects, not just the young ones. In applications with millions of long-lived objects — typical in web services, data pipelines, and background workers — the incremental overhead became the dominant cost rather than a marginal optimization.
Before upgrading, it’s worth understanding what you’re going back to. The generational GC divides objects into three generations based on how long they survive between collections:
import gc
# Inspect current collection thresholds (generational GC defaults)
print(gc.get_threshold())
# Output: (700, 10, 10)
#
# Generation 0: triggers every 700 allocations since last gen-0 collection
# Generation 1: triggers when gen-0 has run 10 times without gen-1 collecting
# Generation 2: triggers when gen-1 has run 10 times without gen-2 collecting
#
# Default: gen-0 runs ~every 700 allocs
# gen-1 runs ~every 7,000 allocs
# gen-2 runs ~every 70,000 allocs
The tiers work like this:
Generation 0 is the youngest generation. All newly created objects start here. When the threshold of 700 allocations is reached since the last gen-0 collection, the collector scans all gen-0 objects. Survivors are promoted to generation 1.
Generation 1 holds objects that survived at least one gen-0 collection. It collects less frequently — when gen-0 has run 10 times without a gen-1 collection. This means gen-1 runs roughly every 7,000 allocations by default.
Generation 2 is the oldest generation, holding long-lived objects like global variables, loaded modules, and cached data. It runs only when gen-1 has run 10 times without a gen-2 collection — roughly every 70,000 allocations by default.
This tiered approach means frequently-created objects (request handlers, temporary data structures) are collected rapidly, while stable infrastructure objects are checked infrequently. The math works because most Python objects have short lifespans.
import gc
# Tune thresholds for your workload
# More aggressive collection:
gc.set_threshold(500, 8, 8)
# Less aggressive (larger batches, fewer collections):
gc.set_threshold(1000, 15, 15)
# View current settings
print(gc.get_threshold())
Adjusting thresholds is straightforward, but the defaults work well for most applications. Only tune if you have evidence from profiling that the defaults are causing issues.
The Security Fixes in 3.14.5
Python 3.14.5 isn’t solely a GC story. It ships with several security-relevant fixes that directly affect production deployments, particularly for teams running remote debugging, processing untrusted data, or handling file uploads on Windows.
Remote Debugging Hardening
import sys
# The remote debugging protocol enables external tools
# to attach to a running CPython process and execute code
# remotely via sys.remote_exec() and the debug protocol.
#
# In 3.14.4 and earlier, the remote debug offset tables
# were not validated before being used to size memory reads
# or interpret remote memory layouts.
#
# gh-148178 fixed this by adding validation.
# Verify your Python version after upgrade
print(sys.version_info)
# Expected: sys.version_info(major=3, minor=14, micro=5, ...)
The fix in gh-148178 validates remote debug offset tables before using them to size memory reads. This prevents potential memory corruption or information disclosure through the debug protocol. If you use pdb‘s remote execution capabilities or any tool that leverages sys.remote_exec(), upgrading from 3.14.4 to 3.14.5 is critical.
For most production applications, remote debugging should be disabled entirely outside of controlled diagnostic sessions:
# Disable remote debugging via environment variable
export PYTHON_DISABLE_REMOTE_DEBUG=1
# Or compile with the flag defined
CFLAGS="-DPYTHON_DISABLE_REMOTE_DEBUG=1" ./configure
Decompressor Stale Pointer Fix
Multiple decompression libraries in the standard library had a dangling pointer vulnerability when memory allocation failed during decompression:
import lzma
import bz2
# Before 3.14.5, if lzma.LZMADecompressor.decompress()
# raised MemoryError mid-stream, the internal buffer pointer
# could dangle. A subsequent decompress() call on the same
# object would read or write through a stale pointer to the
# already-released caller buffer.
#
# This affected: lzma.LZMADecompressor, bz2.BZ2Decompressor,
# and the internal zlib._ZlibDecompressor.
def safe_decompress(lzma_data: bytes) -> bytes:
"""Safely decompress LZMA data with proper error handling."""
try:
decompressor = lzma.LZMADecompressor()
return decompressor.decompress(lzma_data)
except MemoryError:
# Always create a fresh decompressor after MemoryError
# to avoid using stale state
raise
except lzma.LZMAError as e:
raise ValueError(f"Invalid LZMA data: {e}")
# Safe pattern for streaming decompression
class StreamingDecompressor:
def __init__(self) -> None:
self._decompressor: lzma.LZMADecompressor = lzma.LZMADecompressor()
def feed(self, chunk: bytes) -> bytes:
try:
return self._decompressor.decompress(chunk)
except MemoryError:
# Create fresh instance — critical after MemoryError
self._decompressor = lzma.LZMADecompressor()
raise
The fix in gh-148395 prevents reading or writing through stale pointers after a MemoryError during decompression. This is particularly relevant for applications that process large compressed files, download compressed data from untrusted sources, or handle user-uploaded compressed archives.
Archive Extraction Path Traversal (Windows)
import shutil
import tempfile
import os
# gh-146581 fixed a vulnerability in shutil.unpack_archive()
# for ZIP files on Windows. Archives containing Windows drive
# prefixes (like "C:\evil\payload.exe" in path entries) could
# write files outside the intended destination directory.
#
# Fixed behavior:
# - Invalid paths with drive prefixes are now skipped
# - Files with ".." in the name (like "foo..bar") are no
# longer skipped — only actual path traversal is blocked
def safe_extract(archive_path: str, dest_dir: str) -> list[str]:
"""Safely extract a ZIP archive to the destination directory."""
extracted: list[str] = []
with tempfile.TemporaryDirectory() as tmpdir:
extracted = shutil.unpack_archive(
archive_path,
extract_dir=tmpdir
)
# Verify all extracted files are within dest_dir
for root, _dirs, files in os.walk(tmpdir):
for filename in files:
filepath = os.path.join(root, filename)
rel = os.path.relpath(filepath, dest_dir)
if rel.startswith(".."):
raise SecurityError(
f"Path traversal detected: {rel}"
)
# Move to final destination
dest_path = os.path.join(dest_dir, rel)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
shutil.move(filepath, dest_path)
return extracted
If you extract untrusted ZIP archives on Windows — common in web applications, email gateways, API endpoints that accept file uploads, or backup restoration pipelines — this fix is non-negotiable. The old behavior could allow an attacker to write files anywhere on the Windows drive.
Diagnosing GC-Related Memory Issues
If you’re experiencing memory issues on Python 3.14.0–3.14.4, you can diagnose whether the incremental GC is the culprit before upgrading:
import gc
import tracemalloc
import time
def diagnose_gc_memory() -> dict:
"""Comprehensive GC memory diagnostic."""
# Start memory tracing
tracemalloc.start(50)
# Record baseline
baseline_current, baseline_peak = tracemalloc.get_traced_memory()
# Run a representative workload
workload_duration = 60 # seconds
start_time = time.time()
iteration = 0
while time.time() - start_time < workload_duration:
# Simulate typical web workload: create and discard
# objects with potential reference cycles
for _ in range(1000):
request_data = {
"url": f"http://example.com/path/{iteration}",
"headers": {"user-agent": "test"},
"cookies": {"session": f"abc{iteration}"},
}
# Create a cycle: dict references itself
request_data["self_ref"] = request_data
iteration += 1
# Force a GC collection
gc.collect()
# Take a snapshot after workload
post_current, post_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
# Calculate growth rate
time_elapsed = workload_duration
memory_growth_mb = (post_current - baseline_current) / 1024 / 1024
growth_rate_mb_per_hour = (memory_growth_mb / time_elapsed) * 3600
# GC stats
gc_stats = {
"collections": dict(gc.get_count()),
"thresholds": gc.get_threshold(),
"garbage_objects": len(gc.garbage),
}
return {
"baseline_mb": round(baseline_current / 1024 / 1024, 2),
"post_workload_mb": round(post_current / 1024 / 1024, 2),
"peak_mb": round(post_peak / 1024 / 1024, 2),
"growth_mb": round(memory_growth_mb, 2),
"growth_rate_mb_per_hour": round(growth_rate_mb_per_hour, 2),
"gc_stats": gc_stats,
}
# Run the diagnostic
diagnostic = diagnose_gc_memory()
for key, value in diagnostic.items():
print(f"{key}: {value}")
If your growth rate exceeds 10 MB/hour on Python 3.14.0–3.14.4, upgrading to 3.14.5 should provide immediate relief. The generational GC’s more aggressive collection of young objects means reference leaks get caught sooner.
Common Migration Mistakes
Teams reacting to GC memory issues often make mistakes that are worse than the original problem. Here’s how to avoid them.
Mistake 1: Disabling GC Entirely
# WRONG — never disable GC in production
# This prevents cycle collection entirely and causes
# unbounded memory growth with any circular references
gc.disable()
Some teams, alarmed by the memory issues in 3.14.3 and 3.14.4, disabled the garbage collector entirely. This is arguably worse than running the incremental GC. Any object cycle — a model referencing its parent, a cache entry pointing back to its key, a request handler referencing its parent request — becomes permanent memory. In a long-running process, this leads to memory exhaustion regardless of the GC strategy.
Do this instead:
# RIGHT — tune thresholds if needed, but don't disable
# More frequent collection for memory-sensitive workloads
gc.set_threshold(500, 8, 8)
Mistake 2: Using gc.set_debug() in Production
# WRONG — gc.DEBUG_STATS and gc.DEBUG_SAVEALL produce
# enormous memory overhead by keeping references to all
# collected objects and logging every collection event
gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_SAVEALL)
The debug flags are powerful diagnostic tools for development and staging, but they are production anti-patterns. gc.DEBUG_SAVEALL alone can multiply memory usage by 10x or more because it preserves every collected (and uncollectable) object in gc.garbage for later inspection. gc.DEBUG_STATS generates statistics on every collection, which itself adds measurable overhead to each collection cycle.
Do this instead:
# RIGHT — use tracemalloc for production memory profiling
import tracemalloc
tracemalloc.start(25) # 25 frames deep for meaningful stacks
# ... run your workload ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
Mistake 3: Not Checking the Python Version Before Deploying
# WRONG — assume all 3.14.x versions are equal
# 3.14.0 through 3.14.4 all shipped with the incremental GC
# Only 3.14.5+ reverted to the generational collector
#
# A deployment script that checks sys.version_info[:2] == (3, 14)
# won't distinguish between the good and bad versions
import sys
# RIGHT — check the micro version explicitly
def ensure_safe_python_version() -> None:
"""Ensure we're running a version with the safe GC strategy."""
if sys.version_info[:3] < (3, 14, 5):
version_str = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
raise RuntimeError(
f"Python {version_str} has the problematic incremental GC. "
"Upgrade to Python 3.14.5+ immediately."
)
ensure_safe_python_version()
If you’re stuck on an older version for any reason, the mitigation is to raise the GC thresholds to delay collection and reduce the incremental state overhead:
# Mitigation for 3.14.0-3.14.4 only
if sys.version_info[:3] < (3, 14, 5) and sys.version_info[:2] == (3, 14):
# Raise thresholds to reduce collection frequency
gc.set_threshold(1000, 15, 15)
Mistake 4: Assuming GC Collection Guarantees
# WRONG — gc.collect() returns a count, but doesn't
# guarantee all cycles were collected
# The return value is the number of unreachable objects found
# and freed, not a guarantee that zero cycles remain
collected = gc.collect()
assert collected == 0, "Memory leak detected!"
# RIGHT — collect may return 0 even if cycles exist
# across multiple generations. Run multiple times:
while gc.collect() > 0:
pass
The gc.collect() function returns the number of objects freed, but it doesn’t guarantee that all reference cycles have been eliminated. Some cycles may span generations that haven’t been collected yet. If you need to verify complete cycle collection (for testing or cleanup), run gc.collect() in a loop until it returns zero.
Upgrading to 3.14.5
The migration path from 3.14.4 to 3.14.5 is straightforward because the GC reversion is a drop-in replacement. The API, behavior, and semantics are identical to Python 3.13 — which is exactly the point of this reversion.
# Using pyenv (recommended for development and CI)
pyenv install 3.14.5
pyenv global 3.14.5
# Using uv (for project-level Python management)
uv python install 3.14.5
uv python pin 3.14.5
# Using system package manager (Debian/Ubuntu)
sudo apt update && sudo apt install python3.14 python3.14-venv
For containerized deployments, the official Docker images are already available:
# RIGHT — pin explicitly to 3.14.5 for the safe GC
FROM python:3.14.5-slim
# Update pip and install dependencies
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen
COPY . .
CMD ["python", "-m", "app"]
A few important notes for the upgrade:
- macOS and Windows binary releases now include an experimental JIT compiler. This is opt-in and should be tested thoroughly before production deployment.
- Android binary releases are now officially available for the first time.
- Python no longer provides PGP signatures for release artifacts. Instead, Sigstore is recommended for verification, per PEP 761. On Windows, the traditional installer is being replaced by the Python Install Manager, available from the Windows Store.
- Bundled pip has been updated to version 26.1.1.
What’s Next
The incremental garbage collector isn’t dead — it remains available for the free-threaded build where it serves a fundamentally different purpose: avoiding GIL contention during collection in GIL-disabled interpreters. For standard GIL-enabled Python, the generational GC is back as the default and will remain there.
Looking ahead, Python 3.15’s October 1, 2026 release (per PEP 790) focuses on production debugging and developer tooling improvements. Notable features include PEP 814’s frozendict as a new built-in immutable dictionary type, and the stabilization of the free-threaded ABI through PEP 803’s abi3t mechanism.
But for right now, with Python 3.14.5 released today, the recommendation is clear: upgrade immediately if you’re on 3.14.0–3.14.4, especially if you’re running long-lived services that process SSL connections or handle compressed data.
References
- Python 3.14.5 Changelog
- CPython Issue #142516 — Observed memory leak in ssl library: Python 3.14 GC issue
- CPython PR #148687 — Forward-port generational GC
- CPython PR #143685 — Fix reference leaks in ssl.SSLContext objects
- Python Insider — Python 3.14.5 release candidate
- Remote Debugging Protocol Documentation
- PEP 761 — Python Release Signing with Sigstore
- PEP 790 — Python 3.15 Development Schedule
- Tracemalloc — Memory tracing module documentation
- GitHub issue 146581 — shutil.unpack_archive path traversal fix
- GitHub issue 148395 — Decompressor dangling pointer fix
No comments yet. Be the first to leave a comment!