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…
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:
- ✅ Create User model
- ✅ Create Company model
- ✅ Add foreign keys
- ✅ Deploy
What ACTUALLY happened:
- Create User model
- User migration needs Company to exist (ForeignKey target)
- Create Company model
- Company migration needs User to exist (ForeignKey target)
- Circular dependency detected
- Migration system gives up
- Production deploy fails
- Database is in inconsistent state
- Rollback doesn’t work because migrations are half-applied
- Website is down
- Manager is calling
- 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:
- Implicit same-app dependency: Each migration depends on the previous migration in the same app
- Explicit foreign key dependency: AddField with ForeignKey depends on CreateModel of target
- Manual dependencies: Whatever you specify in
dependencies = [...] - 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:
- 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-syncdbbefore 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=Falseto 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