Skip to main content
Jonathan Andrei
Back to all posts
Aug. 202612 min read

Python Can't Weakref a ValueError. Sentry's Deduplication Found Out the Expensive Way: 1024 KB per Live Asyncio Task.

sentry-python's DedupeIntegration stops the same error being reported twice by remembering the last exception. Remembering an exception is exactly what you must not do: an exception holds its traceback, a traceback holds its frames, a frame holds every local. The SDK knew this and stored a weakref.ref(exc). The three lines under the comment `# we can only weakref non builtin types` do the one thing the weakref existed to prevent: an `except TypeError:` catches Python's refusal to weakref a builtin and quietly holds the exception itself. Under asyncio each task keeps its own copy in a ContextVar, so 200 long-lived sessions pin 205 MB, all reachable after gc.collect(). Two attempts before the fix — a value fingerprint that fails the SDK's own tests, an id-based fingerprint that collapses 2000 distinct errors into 2 keys — then the identity-token approach that keeps behaviour unchanged and takes retained down to 0.5 MB.

BugSmashClearTheLineupSentryPythonasyncioweakrefContextVarMemory LeakOpenSourceShowDev
I created this post and the fix for DEV's Summer Bug Smash: Clear the Lineup, powered by Sentry. #bugsmash
The claim in one sentence: sentry-python's DedupeIntegration retains 1024 KB per live asyncio task because Python cannot take a weak reference to a builtin exception, and the SDK's fallback holds the exception itself. Measured on master across 200 long-lived sessions holding 1 MB each: 205.5 MB retained, 200 / 200 sessions still reachable after gc.collect(). After the fix (attach a weak-referenceable identity token to the exception, weakref the token): 0.5 MB, 0 / 200. Deduplication behaviour is unchanged — the token is one-per-exception and lives precisely as long as the exception does, so `last_seen is token` is equivalent to the previous `last_seen is exc`.
Screenshot of sentry_sdk/integrations/dedupe.py showing a bare `except TypeError:` fallback that stores the exception itself when weakref.ref(exc) raises. The comment on the line directly above reads `# we can only weakref non builtin types`.
The SDK's own comment narrates what is about to go wrong. `weakref.ref(exc)` raises TypeError when exc is a builtin (ValueError, KeyError, TypeError — the ones almost every real error actually is), and the fallback stores the exception itself. That is the only line the weakref existed to prevent.

Why asyncio makes it hurt

`_last_seen` is a `ContextVar`. Under asyncio, every task gets its own copy of context. So this is not one retained exception process-wide. It is one retained exception per live task, each one dragging along its traceback and every frame local reachable from it. The reporter of issue #6094 was seeing this in an asyncio web crawler where the frames held fetched response bodies between 500 KB and 1 MB.

My first instinct was to call it an unbounded leak. I built a worker pool, ran it, and it stayed flat. That was worth finding out before I wrote it down anywhere. A fixed pool does not grow, because each worker's next error overwrites its previous one. Retention is bounded at (live tasks × payload). The growth story is not errors over time, it is tasks over time: one task per session, per subscription, per connection. So I measured against live task count instead.

Terminal output titled `master`, 200 long-lived sessions, 1 MB each, sentry-sdk 2.68.0, payload 1000 KB per session. Table: 25 live tasks → 26.2 MB retained, 1047 KB per task, 25/25 sessions pinned. 50 → 52.1 MB, 1042 KB, 50/50. 100 → 103.1 MB, 1031 KB, 100/100. 200 → 205.5 MB, 1028 KB, 200/200. Below: `growth per additional live task: 1025 KB` and in red `LEAKING: sessions remain reachable after gc`.
1024 KB retained per live task, dead straight, and `sessions pinned` counts weak references to session objects that are still reachable after gc.collect(). Not a sampling artifact. The garbage collector cannot touch them because a live ContextVar genuinely still points at every one. At 200 concurrent sessions that is 205 MB that never comes back.

The fix I got wrong first

The maintainers had already rejected the obvious fix. The reporter proposed skipping dedupe for builtins, and Sentry declined, saying they wanted 'a more robust fingerprinting approach that can also be used for built-in exceptions' instead. So I proposed a fingerprint: exception type, message, and the origin frame from the traceback. All immutable primitives, nothing retained. I posted it on the issue. Then the existing test suite told me I was wrong, in two different ways.

Terminal output of the two test cases the value-fingerprint approach fails. `test_breadcrumbs` calls `capture_exception(ValueError())` twice with distinct never-raised exceptions — identical fingerprints, second event wrongly dropped. `test_option_before_breadcrumb` raises three separate `ValueError("aha!")` from the same line — three identical fingerprints, two events wrongly deduplicated.
That is the flaw in the whole idea. A value fingerprint cannot tell 'the same exception object twice' apart from 'the same error raised repeatedly from the same line.' The first must deduplicate. The second must not. No fingerprint distinguishes them, because by value they are the same.

The attempt that came before mine

Late on, I went looking through the fork's branch list and found two abandoned maintainer branches from September 2025: `antonpirker/dedupe-integration-memory-usage` and `antonpirker/make-dedupe-integration-more-memory-efficient`. Neither was merged. The first one fingerprints on `(type_module, type_name, id(exc_value))`. That `id()` is the interesting part. Once you stop retaining the exception, which is the entire point of the change, its address becomes immediately reusable, and CPython reuses addresses aggressively.

Terminal output modelling the id-based fingerprint over 2000 distinct sequentially allocated ValueErrors: distinct exceptions created 2000, distinct fingerprints 2, address-reuse collisions 1998. Each collision is a distinct error that would be treated as a duplicate and dropped.
2000 distinct errors collapse into two fingerprints. Each collision is a real error dropped as a duplicate. The identity-token approach avoids this by construction: the token is a real object whose lifetime is tied to the exception, so it cannot be confused with a later object at the same address.

The fix that shipped

Keep identity. Stop holding the object to express it. Attach a small weak-referenceable `_DedupeToken` to the exception (one per exception, alive precisely as long as the exception is), and weakly reference the token instead of the exception. `last_seen is token` is equivalent to the previous `last_seen is exc`, so deduplication behaviour is unchanged — which matters given the issue thread's explicit request not to change behaviour ahead of the fingerprinting rework. Attaching a private `_sentry_*` attribute to a user object to carry a weakref follows existing precedent in this SDK: `sentry_sdk/integrations/django/__init__.py` sets `_sentry_drf_request_backref = weakref.ref(...)` on the request.

Two-block code diff titled 'The fix. Keep identity. Stop holding the object to express it.' Top: `class _DedupeToken: __slots__ = ("__weakref__",)`. Bottom: `token = _identity_token(exc); if token is not None: new_last_seen = weakref.ref(token); is_duplicate = last_seen is token`.
The whole fix. A five-byte-of-slots token exists just to be weak-referenceable so the ContextVar can stop holding the traceback. Behaviour is unchanged; the ContextVar no longer keeps a frame local alive.
Terminal output titled `patched`, same 200 long-lived sessions of 1 MB each, sentry-sdk with the identity-token fix applied. Table: 25 live tasks → 0.6 MB retained, 0/25 pinned. 50 → 0.9 MB, 0/50. 100 → 0.7 MB, 0/100. 200 → 0.5 MB, 0/200.
Same two hundred sessions. Two megabytes total, nothing pinned. Retention no longer scales with live task count.

Sentry's own Seer on it

Then I ran Sentry's own Seer on it. It found the root cause exactly, including that ContextVars are per-task under asyncio, which took me a debugging round to work out myself. It read the SDK source to do it. Then it proposed storing the type and the id of the exception. The same trap. Its fix would drop every distinct error as a duplicate.

Screenshot of Sentry Seer's root-cause output on issue #6094, correctly identifying that _last_seen is a ContextVar that keeps a per-task strong reference to the exception when weakref.ref fails on builtins, retaining the traceback and its frame locals for the lifetime of the task.
Seer's root cause is exact. Its proposed patch is wrong for the same reason attempt 2 was: id() gets reused, so 2000 distinct errors collapse to 2 fingerprints. Worth noting both together: the diagnosis and the patch are not the same problem, and getting one right does not get you the other.

One more twist worth naming, because it is the part that convinced me PRs from Seer need the same review any human PR gets. Seer's prose plan proposed the type + id fingerprint (which collapses distinct errors as shown above). Its actual generated patch did something different, and worse: it wrapped the failing `weakref.ref(exc)` in `except TypeError: pass`, silently dropping every builtin exception from dedupe entirely. In the identity-tuple test I ran the two variants against, that same-address collision pattern would have caused 1999 out of 2000 (100.0%) of the distinct errors in a session to be wrongly deduplicated. The prose it shows you for review is not necessarily the diff it writes.

The tests I added

Five new tests in `tests/integrations/dedupe/test_dedupe.py`, each written to catch a specific regression the two abandoned attempts would have introduced. `test_dedupe_does_not_retain_builtin_exception` weakrefs the token and asserts the exception itself is gc-collectable after the frame returns. `test_dedupe_still_dedupes_builtin_exception` asserts the fix does not break the case that motivates the integration in the first place — the same exception object caught and re-reported must still deduplicate. `test_dedupe_distinguishes_equal_builtin_exceptions` is the value-fingerprint regression: two `ValueError('same')` instances raised from the same line must not deduplicate as each other. `test_dedupe_leaves_unraised_exception_untouched` asserts the token attachment does not mutate exceptions the integration never sees. `test_dedupe_survives_exotic_exception_dict` covers the one Python quirk that could break the token approach: an exception subclass with `__dict__ = None`. In that case `_identity_token` returns None and the integration silently declines to dedupe rather than crash — a fail-safe path that Gemini's review flagged and I wrote a test for.

Gemini's second look on my patch

Before opening the PR I ran the diff through Gemini in adversarial-review mode: 'find every way this breaks.' It suggested six failure modes. Two were real crashes I had not thought about (the `__dict__ = None` case above, and one where the token attachment could race in a threaded context — I resolved that by making the attachment idempotent). One was refuted by reading the SDK's own precedent for `_sentry_*` private attributes on user objects. The remaining three (PII scrubbers stripping the token, exceptions that override `__setattr__`, garbage-collected weakref callbacks firing during shutdown) were all cases where the integration falls back to not-deduping, which is fail-safe: worst case is a duplicate event, not a crash, not a memory leak, not a wrong drop. I added tests for the two real ones and documented the fail-safe path for the other three. That is the pattern I want with AI review: it surfaces cases, I decide which are real, I write the test.

What I took away from this bug: the prose an AI shows you for review is not necessarily the diff it writes, and the diagnosis and the patch are two different problems. Seer got the root cause exactly right and then wrote a patch that would silently drop every builtin exception. Gemini's adversarial review was the opposite: it surfaced six cases, three of which were real, and gave me tests to write. The verifier stays a human. What changes is the throughput.
Related project

sentry-python DedupeIntegration Retained 1024 KB per Live Asyncio Task Because Python Cannot Weakref a ValueError. Root-Caused, Fixed with a Weak-Referenceable Identity Token, Repro'd, Measured, and Prepared as an Upstream PR to Fix Issue #6094.

View the project