Python "RecursionError: Maximum Recursion Depth Exceeded" in ORM Model
A RecursionError specifically involving ORM models almost always comes from a circular reference between related models — serializing or printing one model triggers serialization of a related model, which in turn references back to the original, and so on indefinitely until Python's recursion limit is hit.
The Problem
Serializing, printing, or converting an ORM model to a dictionary fails with:
RecursionError: maximum recursion depth exceeded
RecursionError: maximum recursion depth exceeded while calling a Python object
The specific model involved might look completely straightforward when inspected in isolation, with the actual cause only becoming clear once its relationships to other models are considered together.
Why It Happens
Related models commonly reference each other bidirectionally (a User has many Orders, and each Order belongs to a User), which is normal and expected for relational data modeling — the problem arises specifically when serialization or string representation logic naively follows every relationship without any boundary, recursing infinitely between the two related objects. Common causes:
- A custom
__repr__or__str__method that includes a related object's own representation, and that related object's representation in turn includes the original object, creating a direct two-object cycle - Using a generic "convert to dict" or serialization utility that automatically follows every relationship attribute without any depth limit or explicit exclusion of back-references
- An ORM's default JSON serialization (if using a framework's automatic model-to-JSON conversion) similarly following bidirectional relationships without a configured boundary
- A self-referential relationship within a single model (like a category with subcategories, each linking back to its parent) without any depth control during serialization
The Fix
For custom __repr__/__str__ methods, avoid including full representations of related objects — reference them by a simple identifier instead, breaking the cycle:
# Problematic - recurses into Order's own __repr__, which includes User again
def __repr__(self):
return f"User(id={self.id}, orders={self.orders})"
# Fixed - references orders by count or ID only, not full representation
def __repr__(self):
return f"User(id={self.id}, order_count={len(self.orders)})"
For serialization to JSON or dictionaries, explicitly control which fields and relationships are included, rather than relying on an automatic "serialize everything reachable" approach:
def user_to_dict(user):
return {
"id": user.id,
"name": user.name,
"order_ids": [order.id for order in user.orders], # IDs only, not full nested objects
}
If you're using a framework-provided serialization mechanism (like Django REST Framework serializers, or Pydantic models with an ORM framework), explicitly exclude the back-reference field, or use a nested serializer specifically designed to stop at a controlled depth rather than following every relationship indefinitely:
# Example: Django REST Framework - exclude the circular back-reference
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ['id', 'total', 'created_at'] # deliberately omit 'user' to avoid the cycle
For self-referential relationships (a tree or hierarchy within a single model), implement serialization with an explicit maximum depth parameter, so it naturally terminates rather than following the chain indefinitely:
def category_to_dict(category, depth=0, max_depth=3):
result = {"id": category.id, "name": category.name}
if depth < max_depth:
result["children"] = [
category_to_dict(child, depth + 1, max_depth)
for child in category.children
]
return result
Still Not Working?
If the recursive cycle isn't obvious from reviewing your own custom methods, and you suspect it's coming from a third-party library's automatic serialization behavior instead, temporarily increase Python's recursion limit and catch the resulting stack trace to see the actual chain of calls leading up to the error — this reveals exactly which methods and objects are involved in the cycle, even when it's happening inside library code rather than your own directly written functions:
import sys
sys.setrecursionlimit(100) # lower temporarily to get a shorter, more readable traceback
try:
problematic_operation()
except RecursionError:
import traceback
traceback.print_exc()
Lowering the limit temporarily (rather than raising it) produces a much shorter, more readable traceback showing the repeating pattern clearly, which is often more useful for diagnosis than the very long traceback a higher limit would produce before finally failing.