contrib.pydantic builds a fresh TypeAdapter per payload — construction dominates validation up to 521x for union hints · Issue #1695 · temporalio/sdk-python · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
contrib.pydantic builds a fresh TypeAdapter per payload — construction dominates validation up to 521x for union hints #1695
For hints that are BaseModel subclasses this is masked by pydantic's class-level core-schema cache. But activity arg/result hints are frequently not classes — Annotated discriminated unions, list[Union[...]], generics — and for those TypeAdapter.__init__ rebuilds the core schema on every single payload. Construction dominates validation by orders of magnitude.
Measured
Toy 3-member discriminated union (runnable below), pydantic 2.x, Python 3.12:
build+validate per payload: 0.134 ms
validate only: 0.0023 ms
ratio: 57x
Production shape (pydantic-ai's CallToolResult, a wide Annotated discriminated union — PydanticAIPlugin inherits this converter): 2.818 ms build vs 0.005 ms validate, 521×. In a wedged activation resolving 792 concurrent activity completions, 11.935 s of the 12.089 s activation was TypeAdapter.__init__ — enough on its own to breach a Lambda worker's deadlock-detection ceiling. It also makes replay and continue-as-new rebuilds scale with history size × union width.
Memoize the adapter on the type hint. We run exactly that in production as a converter subclass — an LRU keyed on the hint (falling back to the un-memoized path for unhashable hints), which took our peak activation from 3.900 s → 0.200 s with no behavior change: the memo returns exactly what a fresh adapter returns for the same inputs, so histories replay identically. Happy to turn that into a PR against contrib/pydantic.py if you'd take it — the only design question is cache scope/eviction (process-global lru_cache(maxsize=...) on a module function was enough for us).
Given those measurements, it definitely seems like a good thing to solve. I don't see any good reason a small LRU cache by default would be a breaking change for anyone, but it would be good to consider if there is any drawback to enabling it by default.
I think probably it would be safest to start with this as experimental opt-in behavior. We can keep the cache behavior simple and see if there is any additional need.
@tconley1428 re: your question about drawbacks of enabling it by default, I found two and handled both in the PR rather than leaving them implicit:
the cache holds hints strongly, so it's bounded at 256 rather than unbounded — a worker sees a small fixed set of signatures, and that stops a pathological caller retaining types forever. and not every hint is hashable, so Annotated[list[X], SomeUnhashableMetadata()] raises TypeError out of lru_cache; that falls back to per-call construction instead of turning a working conversion into an error. the second one is the case I'd most want a second pair of eyes on.
@ZacharyHampton reproduced your numbers before changing anything — 92x here vs your 57x, so machine-dependent multiple but the same effect:
build+validate per payload: 0.1375 ms
validate only: 0.0015 ms
after, measured through the converter itself rather than the raw adapter: 0.0058 ms per call, 199 cache hits to 1 miss.
the test I'd point at is the black-box one: it counts TypeAdapter constructions across two conversions of a single hint and reports built 2 TypeAdapters for one hint against the unfixed converter, so it pins the behavior instead of the presence of a cache. to_payload is untouched — its SchemaSerializer is already hoisted into __init__, so it never had this problem.
Activity
tconley1428 commentedon Jul 29, 2026
Given those measurements, it definitely seems like a good thing to solve. I don't see any good reason a small LRU cache by default would be a breaking change for anyone, but it would be good to consider if there is any drawback to enabling it by default.
I think probably it would be safest to start with this as experimental opt-in behavior. We can keep the cache behavior simple and see if there is any additional need.
fix(contrib/pydantic): reuse TypeAdapters across payloads
PranavMishra28 commentedon Aug 5, 2026
opened #1724 for this.
@tconley1428 re: your question about drawbacks of enabling it by default, I found two and handled both in the PR rather than leaving them implicit:
the cache holds hints strongly, so it's bounded at 256 rather than unbounded — a worker sees a small fixed set of signatures, and that stops a pathological caller retaining types forever. and not every hint is hashable, so
Annotated[list[X], SomeUnhashableMetadata()]raisesTypeErrorout oflru_cache; that falls back to per-call construction instead of turning a working conversion into an error. the second one is the case I'd most want a second pair of eyes on.@ZacharyHampton reproduced your numbers before changing anything — 92x here vs your 57x, so machine-dependent multiple but the same effect:
after, measured through the converter itself rather than the raw adapter: 0.0058 ms per call, 199 cache hits to 1 miss.
the test I'd point at is the black-box one: it counts
TypeAdapterconstructions across two conversions of a single hint and reportsbuilt 2 TypeAdapters for one hintagainst the unfixed converter, so it pins the behavior instead of the presence of a cache.to_payloadis untouched — itsSchemaSerializeris already hoisted into__init__, so it never had this problem.fix(contrib/pydantic): reuse TypeAdapters across payloads (temporalio…