← ~/logs LOG-007

>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 inside atomic()?” — 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.MIRROR points a test DB alias at another so tests don’t need two separate test databases

Slides | Experiment code