>Scaling with Multiple Databases in Django
A 17-line database router for primary/replica splitting in Django, plus the three gotchas you will hit in production.
Jake Howard’s talk at DjangoCon Europe 2026 on primary/replica database routing in Django.
When one database isn’t enough, a pragmatic next step is one primary for writes and read replicas for reads. Django has multi-DB support out of the box but leaves “which query goes where” up to you. Jake walks through a 17-line router and the three real-world gotchas he hit in production.
The router
A database router is a small class with four methods that Django calls for every query. Put a load balancer in front of your replicas and Django only needs two aliases: default (primary) and replica.
class PrimaryReplicaRouter:
@cached_property
def _gfk_models(self):
return _models_with_generic_fields()
def db_for_read(self, model, **hints):
if model in self._gfk_models:
return "default"
if not transaction.get_autocommit(using="default"):
return "default"
return "replica"
def db_for_write(self, model, **hints):
return "default"
def allow_relation(self, obj1, obj2, **hints):
dbs = {"default", "replica"}
if obj1._state.db in dbs and obj2._state.db in dbs:
return True
return None
def allow_migrate(self, db, app_label, **hints):
return db == "default"
The three gotchas
1. Transactions. Reads inside atomic() see zero rows for their own writes because replicas only see committed data. Fix: check transaction.get_autocommit() and fall back to primary inside transactions.
2. get_or_create. Always goes to primary, even for rows that already exist. Fix: a two-step helper that tries .get() on the replica first:
def replica_aware_get_or_create(manager, defaults=None, **kwargs):
try:
return manager.get(**kwargs), False
except manager.model.DoesNotExist:
return manager.get_or_create(defaults=defaults or {}, **kwargs)
3. Generic foreign keys. Django bug #36389: instance.relation.update(...) on a GenericRelation routes to the read connection even though it’s a write. Fix: detect models with GenericForeignKey/GenericRelation at import time and force them to default.
Key takeaways
- A router is a ~20-line class. No library needed
transaction.get_autocommit('default')detects “am I insideatomic()?” — use it to route reads to primary mid-transaction- Put a load balancer in front of replicas so scaling the replica tier is an infra-only change
TEST.MIRRORpoints a test DB alias at another so tests don’t need two separate test databases