Configuring Django Database Routers for Read Replicas
Problem statement: Your Django app runs every query against default, the primary is CPU-bound on SELECTs, and you need DATABASE_ROUTERS to steer reads to replicas and writes to the primary — without sprinkling .using() across the codebase or breaking migrations.
Symptom Identification
Watch for these before and during a router rollout:
- The primary’s read IOPS and connection count dominate while provisioned replicas idle — Django has no
DATABASE_ROUTERSconfigured, so everyQuerySetusesdefault. - After adding a router, migrations fail with
relation already existsor attempt to run against a replica —allow_migrateis missing or returnsTruefor replica aliases. - Read-your-writes bugs: a form saves a row, the redirect target reads a replica that has not applied the change, and the object appears missing. This is replication lag surfacing through naive routing.
- With
ATOMIC_REQUESTS = True, reads that you expected on a replica still land ondefault, because the request-wrapping transaction pins the connection. django.db.utils.ProgrammingError: cannot execute ... in a read-only transactionon a replica — a write path leaked into a read alias because asave()ran inside a block you forced onto a replica.
Root Cause Analysis
Django’s ORM decides which database serves a query by consulting each router in DATABASE_ROUTERS, in order, until one returns a non-None alias. A router is a plain class exposing four hooks: db_for_read, db_for_write, allow_relation, and allow_migrate. If no router answers, Django falls back to default. There is no built-in read/write split — you must supply the routing policy.
Three design facts govern a correct router. First, db_for_write must always return the primary alias (default), because replicas reject DML. Second, allow_migrate must return True only for the primary so manage.py migrate never touches a read-only standby — replicas receive schema changes through physical or logical replication, not through Django. Third, db_for_read is called once per query, so any replica-selection strategy (random, weighted, least-lag) runs at query granularity, which is exactly where read-your-writes consistency can break if two reads in one request hit replicas at different lag positions.
Django’s ATOMIC_REQUESTS setting complicates this. When on, Django wraps each request in a transaction on default, so queries in that request run on the primary connection regardless of the router’s db_for_read. This is a frequent source of “my replica gets no traffic” confusion and is addressed in the pitfalls section. The router hooks are Django’s slice of the broader ORM middleware routing model.
Architecture: The Router Decision Path
Step-by-Step Resolution
Step 1: Declare replica aliases in DATABASES
Add one alias per replica. Mark each read-only at the session level so a misrouted write fails on the replica rather than silently succeeding against a read-only backend. Reuse the primary’s connection parameters except host.
# settings.py
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app", "USER": "app", "PASSWORD": "secret",
"HOST": "primary.db.internal", "PORT": "5432",
"CONN_MAX_AGE": 60,
},
"replica_1": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app", "USER": "app_ro", "PASSWORD": "secret",
"HOST": "replica-1.db.internal", "PORT": "5432",
"CONN_MAX_AGE": 60,
"OPTIONS": {"options": "-c default_transaction_read_only=on"},
"TEST": {"MIRROR": "default"},
},
"replica_2": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "app", "USER": "app_ro", "PASSWORD": "secret",
"HOST": "replica-2.db.internal", "PORT": "5432",
"CONN_MAX_AGE": 60,
"OPTIONS": {"options": "-c default_transaction_read_only=on"},
"TEST": {"MIRROR": "default"},
},
}"TEST": {"MIRROR": "default"} tells Django’s test runner to treat each replica as an alias of default so it does not try to create a separate test database per replica.
Inline verification: python manage.py dbshell --database replica_1 -c "SELECT pg_is_in_recovery();" returns t.
Step 2: Implement the router class
The router holds all four hooks. Reads pick a replica; writes and relations resolve to default; migrations run only on default.
# app/routers.py
import random
REPLICAS = ["replica_1", "replica_2"]
class ReplicaRouter:
def db_for_read(self, model, **hints):
return random.choice(REPLICAS)
def db_for_write(self, model, **hints):
return "default"
def allow_relation(self, obj1, obj2, **hints):
# Primary and replicas hold the same data, so relations are always allowed
db_set = {"default", *REPLICAS}
if obj1._state.db in db_set and obj2._state.db in db_set:
return True
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
# Schema changes only ever run on the primary
return db == "default"Register it:
# settings.py
DATABASE_ROUTERS = ["app.routers.ReplicaRouter"]Inline verification: Model.objects.all().db returns replica_1 or replica_2; Model.objects.create(...)._state.db returns default.
Step 3: Add weighted replica selection
Uniform random.choice sends equal load to every replica. If replicas differ in size, weight the selection so the larger node absorbs proportionally more reads.
# app/routers.py
import random
# alias -> weight; a weight-2 replica takes twice the read share of a weight-1
REPLICA_WEIGHTS = {"replica_1": 2, "replica_2": 1}
_ALIASES = list(REPLICA_WEIGHTS)
_WEIGHTS = list(REPLICA_WEIGHTS.values())
class ReplicaRouter:
def db_for_read(self, model, **hints):
return random.choices(_ALIASES, weights=_WEIGHTS, k=1)[0]
def db_for_write(self, model, **hints):
return "default"
# allow_relation / allow_migrate unchangedFor lag-aware selection, replace the static weights with a cache-backed lookup that zeroes the weight of any replica whose lag exceeds your budget — the same exclusion logic described in routing queries based on data freshness requirements.
Inline verification: over 3,000 reads, collections.Counter of qs.db shows roughly a 2:1 split between replica_1 and replica_2.
Step 4: Override routing per query with using()
The router is the default policy; using() is the explicit override. Use it for the reads that must be strongly consistent, and for management commands that must target a specific node.
# Force this read onto the primary (read-your-writes)
order = Order.objects.using("default").get(pk=order_id)
# Pin a heavy report to a specific replica
rows = Invoice.objects.using("replica_2").filter(status="paid")
# Writes ignore the router entirely when using() names an alias
Account.objects.using("default").create(owner=user)using() bypasses db_for_read/db_for_write for that QuerySet, so it is the reliable escape hatch when routing policy is not enough.
Inline verification: Order.objects.using("default").get(...)._state.db == "default" even while the router’s default is a replica.
Step 5: Keep migrations on the primary and handle transactions
allow_migrate already restricts schema changes to default. Run migrations only against the primary:
python manage.py migrate --database defaultNever run migrate against a replica alias — the standby is read-only and receives DDL through replication. For the transaction pitfalls, decide on ATOMIC_REQUESTS deliberately:
# settings.py — read-heavy service: do NOT wrap every request in a txn on default
DATABASES["default"]["ATOMIC_REQUESTS"] = FalseWith ATOMIC_REQUESTS = True, each request opens a transaction on default, and reads inside it stay on the primary connection. If you keep it on for write safety, wrap only the mutating views with @transaction.atomic instead, leaving read views free to hit replicas.
Inline verification: with ATOMIC_REQUESTS = False, a GET-only view shows its SELECTs in pg_stat_activity on a replica host, not on the primary.
Step 6: Add sticky-after-write routing for read-your-writes
Pin reads to the primary for the remainder of a request once that request has written, so users see their own changes despite replication lag.
# app/routers.py
import threading, random
_state = threading.local()
REPLICAS = ["replica_1", "replica_2"]
class StickyReplicaRouter:
def db_for_read(self, model, **hints):
if getattr(_state, "wrote", False):
return "default" # read-your-writes: stay on primary this request
return random.choice(REPLICAS)
def db_for_write(self, model, **hints):
_state.wrote = True
return "default"
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
return db == "default"Reset the flag at the end of each request with middleware:
# app/middleware.py
from app.routers import _state
class ResetStickyMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
_state.wrote = False
return self.get_response(request)Inline verification: a POST that writes, followed by a read in the same request, resolves the read to default; a fresh GET-only request resolves to a replica.
Configuration Snippet
The complete, production-shaped router plus middleware registration:
# settings.py
DATABASE_ROUTERS = ["app.routers.StickyReplicaRouter"]
MIDDLEWARE = [
# ... security, session, etc. ...
"app.middleware.ResetStickyMiddleware",
]
DATABASES["default"]["ATOMIC_REQUESTS"] = FalseThis pairs weighted-or-random replica reads with sticky-after-write consistency and keeps migrations and DML on the primary. Threaded servers (Gunicorn sync workers, uWSGI) get correct isolation because threading.local() scopes the sticky flag per worker thread. Under ASGI/async views, replace threading.local() with contextvars.ContextVar so the flag follows await boundaries.
Verification and Rollback
Confirm the split is live:
from django.db import connections
# Which alias served the last read?
Article.objects.all().db # -> 'replica_1' / 'replica_2'
Article.objects.create(...)._state.db # -> 'default'
# Confirm each replica is a standby
with connections["replica_1"].cursor() as c:
c.execute("SELECT pg_is_in_recovery()")
assert c.fetchone()[0] is TrueRoll back instantly by emptying the router list — Django reverts to default for everything with no code change:
# settings.py — kill switch during a replica incident
DATABASE_ROUTERS = []Redeploy, and all traffic collapses onto the primary. Restore the router list once replicas are healthy. Because using() calls still name explicit aliases, audit that no view hardcodes a replica before disabling the router during an incident.
Edge Cases and Gotchas
ATOMIC_REQUESTS silently pins reads to the primary
ATOMIC_REQUESTS = True opens a transaction on default around every request. Django keeps that request’s work on the atomic connection, so db_for_read is effectively ignored for those queries and your replicas stay cold. For a read-scaling deployment, set ATOMIC_REQUESTS = False globally and apply @transaction.atomic only to views that mutate data. This is the single most common reason “the router did nothing.”
Two reads in one request hit different replicas
Because db_for_read runs per query, a view that reads twice can land on replica_1 then replica_2, each at a different lag position. If the second read depends on the first, you can observe non-monotonic data. For sequences of related reads, either wrap them in the sticky flag or pin them with using() to one alias. This is the Django face of the read-your-writes consistency problem.
allow_migrate governs more than migrate
allow_migrate also affects dumpdata, loaddata, and contenttypes/permissions creation. Returning True for a replica can make Django attempt to write permission rows to a read-only node during migrate. Always constrain allow_migrate to db == "default", and pass --database default explicitly in deploy scripts to avoid ambiguity.
FAQ
Why do all my queries hit the primary when ATOMIC_REQUESTS is on?
ATOMIC_REQUESTS wraps each request in a transaction on default. Django keeps the request’s reads on that atomic connection, so db_for_read is bypassed and most queries stay on the primary. Disable ATOMIC_REQUESTS for read-heavy services and instead wrap only mutating views in @transaction.atomic, which frees read views to reach replicas.
How does Django pick a replica when db_for_read returns random?
Django calls db_for_read once per query and uses whatever alias you return. random.choice(REPLICAS) gives uniform spread; random.choices(..., weights=...) lets a larger replica take more load. Because the choice is per query, two reads in one view may land on different replicas with different lag, which can break read-your-writes unless you pin them.
How do I force a read onto the primary after a write in Django?
Use Model.objects.using('default').get(...) for the specific read, or set a thread-local (or ContextVar) sticky flag right after the write so db_for_read returns 'default' for the rest of the request. The router reads that flag and pins subsequent reads to the primary until the replica has caught up, then middleware resets it at request end.
Related
← Back to ORM Middleware for Automatic Query Routing
- Read/Write Splitting in SQLAlchemy with Multiple Binds — the same read/write split expressed through SQLAlchemy’s
get_bind()instead of Django routers. - Prisma Read Replica Routing with the Read Replicas Extension — automatic read/write routing in the Node.js/TypeScript world.
- Pinning Reads to Primary After a Write in a Web Session — session-scoped sticky routing that generalizes the sticky-after-write flag shown here.
- Detecting and Handling Replication Lag in Real Time — the lag telemetry that feeds lag-aware replica weighting.