Fix RecursionError: maximum recursion depth exceeded in Django
This error occurs when a function calls itself infinitely, exceeding Python's default recursion limit of 1000 frames. In Django, this commonly happens with overridden save() methods that call self.save(), circular signal handlers, or serializers with nested relationships. Fix it by breaking the cycle with conditional checks or using update() instead.
Reading the Stack Trace
Here's what each line means:
- File "/app/src/models.py", line 32, in save: The overridden save() method on the model calls self.save() instead of super().save(), creating an infinite loop.
- [Previous line repeated 995 more times]: Python detected the same stack frame repeating 995 additional times, hitting the default recursion limit of 1000.
- File "/app/src/views.py", line 18, in update_profile: The view function triggered the save, which started the infinite recursion chain.
Common Causes
1. Overridden save() calling self.save() instead of super().save()
The model overrides save() to add custom logic but calls self.save() instead of super().save(), causing infinite recursion.
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.bio = self.bio.strip()
self.save(*args, **kwargs) # Bug: should be super().save()
2. Circular post_save signal
A post_save signal handler modifies and saves the same model, triggering the signal again in an infinite loop.
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=Profile)
def update_profile_timestamp(sender, instance, **kwargs):
instance.updated_at = timezone.now()
instance.save() # Triggers post_save again
The Fix
Replace self.save() with super().save() to call the parent class's save method instead of recursively calling the overridden method. This preserves the custom logic while correctly persisting to the database.
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.bio = self.bio.strip()
self.save(*args, **kwargs)
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
self.bio = self.bio.strip()
super().save(*args, **kwargs)
Testing the Fix
import pytest
from django.contrib.auth.models import User
from models import Profile
@pytest.mark.django_db
def test_profile_save_does_not_recurse():
user = User.objects.create_user(username='alice', password='testpass')
profile = Profile(user=user, bio=' Hello World ')
profile.save() # Should not raise RecursionError
profile.refresh_from_db()
assert profile.bio == 'Hello World'
@pytest.mark.django_db
def test_profile_save_strips_whitespace():
user = User.objects.create_user(username='bob', password='testpass')
profile = Profile(user=user, bio=' Spaces everywhere ')
profile.save()
profile.refresh_from_db()
assert profile.bio == 'Spaces everywhere'
@pytest.mark.django_db
def test_profile_save_updates_existing():
user = User.objects.create_user(username='carol', password='testpass')
profile = Profile.objects.create(user=user, bio='Original')
profile.bio = ' Updated '
profile.save()
profile.refresh_from_db()
assert profile.bio == 'Updated'
Run your tests:
pytest
Pushing Through CI/CD
git checkout -b fix/recursion-error-save,git add src/models.py tests/test_models.py,git commit -m "fix: call super().save() instead of self.save() to prevent recursion",git push origin fix/recursion-error-save
Your CI config should look something like this:
name: CI
on:
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: pip install -r requirements.txt
- run: pytest --tb=short
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
The Full Manual Process: 18 Steps
Here's every step you just went through to fix this one bug:
- Notice the error alert or see it in your monitoring tool
- Open the error dashboard and read the stack trace
- Identify the file and line number from the stack trace
- Open your IDE and navigate to the file
- Read the surrounding code to understand context
- Reproduce the error locally
- Identify the root cause
- Write the fix
- Run the test suite locally
- Fix any failing tests
- Write new tests covering the edge case
- Run the full test suite again
- Create a new git branch
- Commit and push your changes
- Open a pull request
- Wait for code review
- Merge and deploy to production
- Monitor production to confirm the error is resolved
Total time: 30-60 minutes. For one bug.
Or Let bugstack Fix It in Under 2 minutes
Every step above? bugstack does it automatically.
Step 1: Install the SDK
pip install bugstack
Step 2: Initialize
import bugstack
bugstack.init(api_key=os.environ["BUGSTACK_API_KEY"])
Step 3: There is no step 3.
bugstack handles everything from here:
- Captures the stack trace and request context
- Pulls the relevant source files from your GitHub repo
- Analyzes the error and understands the code context
- Generates a minimal, verified fix
- Runs your existing test suite
- Pushes through your CI/CD pipeline
- Deploys to production (or opens a PR for review)
Time from error to fix deployed: Under 2 minutes.
Human involvement: zero.
Try bugstack Free →No credit card. 5-minute setup. Cancel anytime.
Deploying the Fix (Manual Path)
- Run pytest locally to confirm save() works without recursion.
- Open a pull request with the super().save() fix.
- Wait for CI checks to pass on the PR.
- Have a teammate review the model's save method.
- Merge to main and verify profile updates work correctly in staging.
Frequently Asked Questions
BugStack tests the save() method with multiple scenarios, verifies no RecursionError is raised, and checks that the custom logic (like stripping whitespace) still works.
All fixes are submitted as pull requests with CI validation. Your team reviews model changes to ensure data integrity before merging.
Use Model.objects.filter(pk=instance.pk).update(field=value) in the signal handler instead of instance.save(). The update() method does not trigger signals.
No. Increasing sys.setrecursionlimit() masks the bug and risks crashing the process with a segfault. Always fix the root cause of the infinite recursion.