Refcount corruption during GC when a `mode='wrap'` validator's `handler` outlives validation (shared validator tree traversed twice)
Describe the bug
Description
When a model has a model_validator(mode='wrap') and validation fails inside a nested validator, the handler object (ValidatorCallable) stays alive as long as the resulting ValidationError is alive: the ValueError stored in the error ctx keeps its traceback, the traceback keeps the field-validator frame, its f_back is the wrap-validator frame, and that frame still holds the local handler.
If a garbage collection runs while the handler is alive, CPython's GC visits every Python object owned by the model's validator tree twice:
SchemaValidator.__traverse__→FunctionWrapValidator { validator, func, config }→ the validator tree (pydantic-core/src/validators/function.rs);ValidatorCallable.__traverse__→InternalValidator { validator, data, context, self_instance }(pydantic-core/src/validators/generator.rs), wherevalidatoris anArcclone of the same tree.
Each Py<PyAny> inside the tree (bound validator methods, model classes, configs) is owned once but visited twice, so subtract_refs drives gc_refs below zero. On a debug build of CPython this is an immediate abort:
Python/gc.c:94: gc_decref: Assertion "gc_get_refs(g) > 0" failed: refcount is too small
object type name: ModelMetaclass
object repr : <class '__main__.A'>
Fatal Python error: _PyObject_AssertFailed: _PyObject_AssertFailedOn a release build the miscounted objects can be treated as unreachable and cleared while still in use, which shows up as random SIGSEGV/SIGABRT later in the process. We hit this in production and CI with django-ninja, whose Schema base class installs a model_validator(mode='wrap') on every model, so every failed request-body validation triggers it.
The same pattern exists for ValidatorIterator (generator validation), which also owns an InternalValidator with an Arc clone of the item validator tree.
Removing the extra reference in the wrap validator (try: return handler(values) finally: del handler) makes the reproducer pass, which confirms the mechanism. A proper fix is probably to stop traversing InternalValidator.validator (the tree is owned and traversed by the SchemaValidator), or to hold a Py<SchemaValidator> inside InternalValidator so that the ownership is expressed as a real Python reference.
Example Code
Run with a debug build of CPython (--with-pydebug); python -X dev is not enough because the assertion lives in gc.c:
import gc
from datetime import datetime
from pydantic import BaseModel, ValidationError, field_validator, model_validator
class A(BaseModel):
created_at: datetime
@field_validator('created_at')
@classmethod
def check(cls, v):
raise ValueError('bad')
@model_validator(mode='wrap')
@classmethod
def _run(cls, values, handler, info):
return handler(values)
kept = []
for _ in range(5):
try:
A(created_at='2020-01-01T00:00:00')
except ValidationError as e:
kept.append(e) # keeps the ValueError and its traceback alive
gc.collect() # aborts on the first iteration
print('ok')Without the model_validator(mode='wrap'), or with del handler in a finally block inside _run, the script prints ok.
Python, Pydantic & OS Version
pydantic version: 2.12.5
pydantic-core version: 2.41.5
pydantic-core build: profile=release pgo=false
python version: 3.13.9 (main, Oct 31 2025, 23:03:53) [Clang 21.1.4 ]
platform: macOS-26.3.1-arm64-arm-64bit-Mach-O
related packages: typing_extensions-4.16.0
commit: unknownCPython 3.13.9 debug build (--with-pydebug, installed via uv python install 3.13+debug), pydantic 2.12.5 / pydantic-core 2.41.5 from PyPI (pydantic-core built from the sdist for the debug ABI). Also reproduced on Linux x86_64 and with pydantic-core built from source with pyo3 0.26.0.
Versions (please complete the following information):
- Python version: 3.13.9
- Django version: 5
- Django-Ninja version: 1.6
- Pydantic version: 2.12.5
- Pydantic core: 2.41.5
Note you can quickly get this by runninng in ./manage.py shell this line:
import django; import pydantic; import ninja; django.__version__; ninja.__version__; pydantic.__version__Source: vitalik/django-ninja