← Back to list

Django Migrations Trapped Me in Circular Dependency Hell

The nightmare where makemigrations works locally but migrate fails in production, and Django’s dependency resolver gives up after showing…

Anas Issath · 2025-10-16 02:31 · 33 claps · 8.2 min read paywalled
#django-migrations #circular-dependencies #django-database #migration-errors #django-deployment
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Django Migrations Trapped Me in Circular Dependency Hell

The nightmare where makemigrations works locally but migrate fails in production, and Django’s dependency resolver gives up after showing you a graph you can’t understand.

If you’re not a Medium member, you can read this article for free via this link: Friend Link

When Migrations Attack Silently

I’ve debugged some truly painful Django issues.

The signal that fires 47 times. The @atomic that allows race conditions. The QuerySet that spawns 10,000 queries.

But nothing — and I mean NOTHING — prepared me for this:

$ python manage.py makemigrations
Migrations for 'users':
  users/migrations/0023_user_company.py
    - Add field company to user

$ python manage.py migrate
Running migrations:
  Applying users.0023_user_company...
django.db.migrations.exceptions.CircularDependencyError: 
  users.0023_user_company (users) depends on companies.0015_company_owner (companies)
  companies.0015_company_owner (companies) depends on users.0022_user_profile (users)

Worked fine locally. Exploded in production.

My Friday deploy turned into a Saturday all-nighter trying to untangle migration dependencies.

The Code That Looked Innocent

Two Simple Foreign Keys

Here’s what I wrote. Textbook Django relationships:

# users/models.py
class User(models.Model):
    username = models.CharField(max_length=150)
    company = models.ForeignKey('companies.Company', on_delete=models.CASCADE)

# companies/models.py  
class Company(models.Model):
    name = models.CharField(max_length=200)
    owner = models.ForeignKey('users.User', on_delete=models.CASCADE)

Looks reasonable, right? A user belongs to a company. A company has an owner who is a user.

The Migration Death Spiral

What I THOUGHT would happen:

  1. ✅ Create User model
  2. ✅ Create Company model
  3. ✅ Add foreign keys
  4. ✅ Deploy

What ACTUALLY happened:

  1. Create User model
  2. User migration needs Company to exist (ForeignKey target)
  3. Create Company model
  4. Company migration needs User to exist (ForeignKey target)
  5. Circular dependency detected
  6. Migration system gives up
  7. Production deploy fails
  8. Database is in inconsistent state
  9. Rollback doesn’t work because migrations are half-applied
  10. Website is down
  11. Manager is calling
  12. Coffee is cold

Why Migrations Break Everything

Dependencies Create Invisible Chains

Here’s Django’s dirty secret: Every migration automatically depends on the previous migration in that app AND any migration that creates a model you reference.

# This migration:
class Migration(migrations.Migration):
    operations = [
        migrations.AddField(
            model_name='user',
            name='company',
            field=models.ForeignKey('companies.Company', ...),
        ),
    ]

# Automatically creates dependencies:
dependencies = [
    ('users', '0022_previous_migration'),  # Previous migration in same app
    ('companies', '0014_create_company'),  # Migration that created Company
]

# But if companies.0014 ALSO references users...
# You get a cycle!

The Local vs Production Trap

Why it works locally but fails in production:

# Locally (SQLite):
$ python manage.py makemigrations users
# Creates migration, no errors

$ python manage.py makemigrations companies  
# Creates migration, no errors

$ python manage.py migrate
# Runs in order, works fine ✅

# Production (PostgreSQL, starting from scratch):
$ python manage.py migrate
# Tries to resolve ALL migrations together
# Detects circular dependency
# FAILS ❌

Locally, you created migrations incrementally. Production tries to apply them all at once and discovers the cycle.

The Proof: Watch Migrations Fail

Simple Circular Dependency Example

Let’s create a minimal reproduction:

# Step 1: Create two apps
$ python manage.py startapp appA
$ python manage.py startapp appB

# Step 2: Define models with circular references
# appA/models.py
class ModelA(models.Model):
    name = models.CharField(max_length=100)
    b_ref = models.ForeignKey('appB.ModelB', on_delete=models.CASCADE)

# appB/models.py
class ModelB(models.Model):
    name = models.CharField(max_length=100)
    a_ref = models.ForeignKey('appA.ModelA', on_delete=models.CASCADE)

# Step 3: Make migrations
$ python manage.py makemigrations
# Works fine locally!

# Step 4: Fresh database migrate
$ python manage.py migrate
# CircularDependencyError!

Same code. Different starting point. Different result.

When This Destroys Production

Scenario 1: The Friday Deploy

The deployment nightmare scenario:

# Friday 4:30 PM
$ git push origin main
# CI starts running tests
# Tests pass ✅

# CI runs migrations on clean database
$ python manage.py migrate --check
# CircularDependencyError ❌

# Deploy fails
# Rollback doesn't help (migrations are the problem)
# Production is on old version
# Weekend debugging session begins

Scenario 2: The New Developer

Onboarding becomes impossible:

# New developer joins team
$ git clone repo
$ python manage.py migrate

# Error: Circular dependency
# They ask: "Is the main branch broken?"
# You say: "No, it works in production..."
# They're confused
# You're embarrassed
# 2 hours wasted on setup

Scenario 3: The Test Database

Every test run recreates database:

$ python manage.py test

Creating test database...
Running migrations:
  Applying users.0001_initial...
  Applying companies.0001_initial...
CircularDependencyError!

# Tests won't even run
# CI is red
# Can't merge PRs
# Development is blocked

Scenario 4: The Squash Migration

Trying to clean up makes it worse:

$ python manage.py squashmigrations users 0001 0025

# Django generates squashed migration
# Squashed migration has all the dependencies
# Circular dependency is now PERMANENT
# You can't unsquash without manual editing
# Migration history is corrupted

How to Fix Circular Dependencies

Option 1: Split Into Multiple Migrations

The “staged migration” approach:

# Migration 1: Create models WITHOUT foreign keys
class Migration(migrations.Migration):
    operations = [
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.BigAutoField(primary_key=True)),
                ('username', models.CharField(max_length=150)),
            ],
        ),
    ]

# Migration 2: Create the other model WITHOUT foreign keys
class Migration(migrations.Migration):
    operations = [
        migrations.CreateModel(
            name='Company',
            fields=[
                ('id', models.BigAutoField(primary_key=True)),
                ('name', models.CharField(max_length=200)),
            ],
        ),
    ]

# Migration 3: Add foreign key from User to Company
class Migration(migrations.Migration):
    dependencies = [
        ('users', '0001_create_user'),
        ('companies', '0001_create_company'),
    ]
    operations = [
        migrations.AddField(
            model_name='user',
            name='company',
            field=models.ForeignKey('companies.Company', null=True, ...),
        ),
    ]

# Migration 4: Add foreign key from Company to User  
class Migration(migrations.Migration):
    dependencies = [
        ('companies', '0001_create_company'),
        ('users', '0002_add_company_field'),
    ]
    operations = [
        migrations.AddField(
            model_name='company',
            name='owner',
            field=models.ForeignKey('users.User', null=True, ...),
        ),
    ]

Pros: Works reliably, no circular dependencies Cons: Many migrations, fields must be nullable initially

Option 2: Use Manual Dependencies

Override automatic dependency detection:

class Migration(migrations.Migration):
    # Override the dependencies manually
    dependencies = [
        ('users', '0021_previous'),
        # DON'T include companies - break the cycle
    ]

    operations = [
        migrations.AddField(
            model_name='user',
            name='company',
            field=models.ForeignKey(
                'companies.Company',
                on_delete=models.CASCADE,
                db_constraint=False,  # No database constraint
            ),
        ),
    ]

Pros: Simple, one migration Cons: No database-level foreign key constraint (less safety)

Option 3: Use String References Carefully

Delay foreign key creation:

# Use string reference with db_constraint=False
class User(models.Model):
    company = models.ForeignKey(
        'companies.Company',
        on_delete=models.CASCADE,
        db_constraint=False,  # Important!
        null=True,
        blank=True,
    )

Then in a later migration, add the constraint:

class Migration(migrations.Migration):
    operations = [
        migrations.RunSQL(
            "ALTER TABLE users_user ADD CONSTRAINT fk_user_company "
            "FOREIGN KEY (company_id) REFERENCES companies_company(id);"
        ),
    ]

Pros: Eventually gets proper constraints Cons: Requires raw SQL, database-specific

Option 4: Rethink Your Schema

Maybe the circular dependency reveals a design problem:

# Instead of User ↔ Company circular reference
# Consider: User → Company, Company → User (through separate table)

class User(models.Model):
    username = models.CharField(max_length=150)
    company = models.ForeignKey('companies.Company', on_delete=models.CASCADE)

class Company(models.Model):
    name = models.CharField(max_length=200)
    # NO owner field here

class CompanyOwnership(models.Model):
    company = models.ForeignKey(Company, on_delete=models.CASCADE)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

Pros: No circular dependency, more flexible (multiple owners) Cons: More complex queries, extra table

Option 5: Manual Migration Editing

Nuclear option — edit migration files directly:

# Edit users/migrations/0023_user_company.py
class Migration(migrations.Migration):
    dependencies = [
        ('users', '0022_previous'),
        # Remove automatic dependency on companies
        # ('companies', '0015_company_owner'),  # REMOVE THIS
    ]

    run_before = [
        ('companies', '0015_company_owner'),  # Run before this instead
    ]

    operations = [...]

Pros: Can fix anything Cons: Easy to corrupt migration history, team coordination needed

The Migration Squashing Trap

When Squashing Makes Things Worse

Squash migrations seem like a good idea:

$ python manage.py squashmigrations users 0001 0050
# Combines 50 migrations into 1
# Supposedly makes things cleaner

But squashing preserves ALL dependencies:

# Original migrations had circular deps at different points
# Squashed migration has ALL of them
# Now the cycle is in ONE migration
# Impossible to deploy

The Right Way to Squash

Only squash after fixing circular dependencies:

# Step 1: Fix circular dependencies first
# (using one of the methods above)

# Step 2: Test on fresh database
$ python manage.py migrate --run-syncdb

# Step 3: If that works, NOW squash
$ python manage.py squashmigrations users 0001 0050

# Step 4: Test squashed migration
$ python manage.py migrate --fake-initial

Understanding Migration Dependencies Graph

Visualize Your Dependencies

Django can show you the dependency graph:

$ python manage.py showmigrations --plan

# Output shows application order:
[ ] users.0001_initial
[ ] companies.0001_initial  
[ ] users.0002_add_company (depends on companies.0001)
[X] companies.0002_add_owner (depends on users.0002) ← Circular!

Look for cycles where later migrations depend on earlier ones in the other app.

The Dependency Rules

Django migration dependencies work like this:

  1. Implicit same-app dependency: Each migration depends on the previous migration in the same app
  2. Explicit foreign key dependency: AddField with ForeignKey depends on CreateModel of target
  3. Manual dependencies: Whatever you specify in dependencies = [...]
  4. Run order: Django topologically sorts migrations by dependencies

A cycle exists when: App A migration X depends on App B migration Y, and App B migration Y depends on App A migration Z (where Z ≤ X).

The Testing Strategy

Test Migrations on Fresh Database

Add this to your CI:

# .github/workflows/test.yml
- name: Test migrations on fresh database
  run: |
    python manage.py migrate --check
    python manage.py migrate --run-syncdb
    python manage.py test

Local Testing Script

Create a test script:

#!/bin/bash
# test_migrations.sh

# Backup current database
python manage.py dumpdata > backup.json

# Drop and recreate database
dropdb mydb
createdb mydb

# Try migrations on fresh database
python manage.py migrate

# Result?
if [ $? -eq 0 ]; then
    echo "✅ Migrations work on fresh database"
else
    echo "❌ Circular dependency detected"
fi

# Restore database
python manage.py loaddata backup.json

The Prevention Checklist

Before Creating Circular References

Ask yourself:

  1. Do I really need bidirectional references?
  • User → Company is enough?
  • Company.owner can be a query: User.objects.get(company=company, is_owner=True)

2. Can one side use a through table?

  • Many-to-many with extra fields avoids direct circular reference

3. Can I stage the migration?

  • Create models first, add foreign keys later

4. Do I have db_constraint=False?

  • Removes database-level dependency

5. Have I tested on fresh database?

  • migrate --run-syncdb before deploy

The Debugging Process

When You Hit Circular Dependency

Follow these steps:

# Step 1: Identify the cycle
$ python manage.py migrate
# Error will show which migrations are in the cycle

# Step 2: Check dependency graph
$ python manage.py showmigrations --plan
# Look for the cycle

# Step 3: Examine the migrations
$ cat users/migrations/0023_user_company.py
$ cat companies/migrations/0015_company_owner.py
# Check the dependencies = [...] in each

# Step 4: Choose a fix strategy
# (use one of the options above)

# Step 5: Test the fix
$ python manage.py migrate --fake-initial
$ python manage.py migrate --run-syncdb

The Hard Truth

No Perfect Solution Exists

Every approach has trade-offs:

  • Split migrations: Many migration files, complex history
  • Manual dependencies: Fragile, easy to break
  • db_constraint=False: Less database safety
  • Redesign schema: Disruptive, requires code changes
  • Manual editing: Risky, team coordination needed

Pick the approach that matches your situation:

  • New project? Design to avoid circular refs from start
  • Existing project? Use staged migrations with nullable fields
  • Quick fix needed? db_constraint=False
  • Production is down? Manual dependency editing
  • Long-term fix? Redesign the schema

The Mental Model

Think of Migrations as a DAG

Directed Acyclic Graph (DAG):

users.0001 → users.0002 → users.0003
                ↓
companies.0001 → companies.0002

If you create a cycle, it’s no longer acyclic:

users.0001 → users.0002 → users.0003
                ↓            ↑
companies.0001 → companies.0002
                      ↓
                 (cycle!)

Django’s migration resolver REQUIRES a DAG. Cycles break it.

The Takeaway Summary

  • Circular migration dependencies occur when apps reference each other’s models
  • Works locally (incremental migrations) but fails in production (fresh database)
  • Split foreign key additions into multiple staged migrations
  • Use db_constraint=False to break dependency cycles
  • Always test migrations on fresh database before deploy
  • Squashing migrations can make circular dependencies permanent
  • Consider schema redesign if circular references are common
  • No perfect solution exists — choose trade-offs wisely

Finally: The Friday Night Lesson

This is peak Django pain.

A local success that becomes a production disaster.

A tool meant to make database changes easy that traps you in dependency hell.

A graph algorithm that fails silently until deploy time.

It’s not a bug. It’s a fundamental limitation of dependency resolution. Which somehow makes it worse.

Next time you add a foreign key, ask yourself:

  • “Does this create a circular dependency?”
  • “Have I tested on a fresh database?”
  • “Can I stage this migration?”
  • “Do I need db_constraint=False?”

If you’re unsure, test early. Test often.

Because in Django migrations, what works locally might explode in production.

And the error message won’t help you fix it.

And remember: In Django, migrations are like dominoes. One wrong dependency and they all fall down.

Pro tip: Set up a CI job that runs python manage.py migrate on a fresh database for every PR. Catch circular dependencies before they reach production.

Thanks for reading! ❤

If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.

Let’s keep building better, together.


메타데이터
post_id
1adc8affe28d
slug
django-migrations-trapped-me-in-circular-dependency-hell-1adc8affe28d
url
https://medium.com/@anas-issath/django-migrations-trapped-me-in-circular-dependency-hell-1adc8affe28d
canonical_url
https://medium.com/@anas-issath/django-migrations-trapped-me-in-circular-dependency-hell-1adc8affe28d
author_url
https://medium.com/@anas-issath
status
ok
fetched_at
2026-08-11 23:03:24