Filter concepts by levelShowing all levels.

Django · Section 76

Read Replicas

Level
advanced
Read
26 min
Concepts
2

A replica is a copy of the primary that receives its changes and serves reads, and Django routes to it through a router — a plain class answering four independent questions per query. Two of the four are the ones people write; the other two are the ones that fail far from where they were omitted. `allow_relation` must return `True` across a primary and its replicas, because Django otherwise refuses to relate objects that came from different aliases, and the resulting error surfaces in code that never mentions databases. `allow_migrate` should permit only the primary, since a replica is maintained by replication rather than by your migrations. Keep the whole router to arithmetic over a precomputed list: it is consulted on every query, so a health probe inside it multiplies by your query rate rather than by your request rate. The harder half is that replication is asynchronous, and Django states plainly that it "doesn't provide any solution for handling replication lag". A row committed on the primary takes time to appear on a replica — usually milliseconds, sometimes seconds under a bulk write — and in that window a routed read does not see it. The visible symptom is a user saving a form, being redirected, and getting a 404 for the thing they created, which is reported as flaky because it depends on timing and never reproduces in development. So sort reads into two boxes. Most may be slightly stale, and that is what makes replicas worth having. Two categories may not: anything the same user is about to see immediately after their own write, handled with a short sticky window that pins their reads to the primary; and anything a *write decision* depends on — stock before reserving, a balance before a withdrawal, a permission before an action — which always reads from the primary inside the transaction that will write, because deciding on data you already know is stale produces a wrong write rather than a stale view. Finally, lag is not only a latency number. On failover, a promoted replica never receives what had not arrived, so committed writes disappear with no error anywhere — which makes the size of the lag window the size of the potential data loss.

What is true here

  1. A router answers four independent questions; None means "no opinion", not "use default".
  2. allow_relation and allow_migrate are the two that fail far from where they were skipped.
  3. The router runs per query — keep it to arithmetic, never a health check.
  4. Sticky reads after a write are what stop a user 404ing on the thing they just created.
  5. Lag is a durability number: on failover, whatever had not replicated is lost.

What you will be able to do

  • Write a complete router, including the two methods most examples omit
  • Decide which reads may be stale and which may never be
  • Implement read-your-own-writes without sending all traffic to the primary
  • Explain what replica lag means for data loss during a failover
Where each query goes, and the two that must not be routed
writereadyesnoyesno — staleis fineasync stream

A query

DATABASE_ROUTERS

asked in order until one returns an alias

Write → primary

db_for_write, always

This user wrote recently?

sticky window after any POST/PUT/PATCH/DELETE

Does a write depend on it?

stock · balance · permissions

Primary

the two reads that may never be stale

Replica pool

everything else — dashboards, search, reports

Replication lag

asynchronous — Django provides no solution for it

On failover: unreplicated writes are lost

the window size is the loss size

  • A query
    • leads to DATABASE_ROUTERS
  • DATABASE_ROUTERS — asked in order until one returns an alias
    • leads to Write → primary (write)
    • leads to This user wrote recently? (read)
  • Write → primary — db_for_write, always
    • leads to Replication lag (async stream)
  • This user wrote recently? — sticky window after any POST/PUT/PATCH/DELETE
    • leads to Primary (yes)
    • leads to Does a write depend on it? (no)
  • Does a write depend on it? — stock · balance · permissions
    • leads to Primary (yes)
    • leads to Replica pool (no — stale is fine)
  • Primary — the two reads that may never be stale
  • Replica pool — everything else — dashboards, search, reports
  • Replication lag — asynchronous — Django provides no solution for it
    • leads to Replica pool
    • on error, leads to On failover: unreplicated writes are lost
  • On failover: unreplicated writes are lost — the window size is the loss size

The router

Four questions asked per query, and the two answers most examples leave out.

Read/write splitting with a database router

coreadvanced

A replica is a copy of the primary database that receives its changes and serves reads. To use one, add it to `DATABASES` and write a router — a class with `db_for_read()`, `db_for_write()`, `allow_relation()` and `allow_migrate()` — then list it in `DATABASE_ROUTERS`. Django asks each router in turn until one returns a database name, so a router that returns `None` is saying "no opinion" rather than "use the default". Writes go to the primary, reads can go to a replica, and migrations must only ever run against the primary.

Think of it as

The router is a routing table, not a policy engine — it is consulted per query with the model and some hints, and it answers with a database alias or abstains. Keeping it that simple is what makes it predictable, because it runs on every single query and anything expensive or stateful in there is multiplied by your query rate. Two of the four methods are easy to underestimate. `allow_relation()` exists because Django refuses to relate objects that came from different databases unless a router says it is fine; with a primary and its replicas that is always fine, since they hold the same data, and forgetting it produces confusing errors when an object read from a replica is assigned to a foreign key on an object being written to the primary. `allow_migrate()` is the safety rail: a replica is maintained by replication, not by your migrations, so running `migrate` against one is either an error or — worse, if it is writable — a divergence that replication will later conflict with. Return `db == "primary"` there and you cannot make that mistake. The last piece is that routing is a default, not a rule. Django gives you `using()` on a queryset and `save(using=…)` on a model to override it per call, and the interesting reads are the ones where you deliberately do that: anything that must see a write that just happened has to name the primary explicitly, because the router has no way to know that.

python
class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):  return random.choice(["replica1", "replica2"])
    def db_for_write(self, model, **hints): return "primary"

What we're doing: A complete router, including the two methods people leave out, plus the settings that make a replica safe.

shop/routers.pypython
import random

REPLICAS = ["replica1", "replica2"]
ALL = {"primary", *REPLICAS}


class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        return random.choice(REPLICAS)

    def db_for_write(self, model, **hints):
        return "primary"

    def allow_relation(self, obj1, obj2, **hints):
        # Primary and replicas hold the same rows, so relations across them
        # are always fine. Without this, mixing a replica read with a primary
        # write raises a cross-database relation error.
        if obj1._state.db in ALL and obj2._state.db in ALL:
            return True
        return None

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        # Replicas are maintained by replication, never by migrate.
        return db == "primary"


# ---- settings.py -------------------------------------------------------
DATABASES = {
    "default": {},                                  # deliberately empty: force explicit routing
    "primary": {**BASE, "HOST": env("DB_PRIMARY_HOST")},
    "replica1": {**BASE, "HOST": env("DB_REPLICA1_HOST"), "TEST": {"MIRROR": "primary"}},
    "replica2": {**BASE, "HOST": env("DB_REPLICA2_HOST"), "TEST": {"MIRROR": "primary"}},
}
DATABASE_ROUTERS = ["shop.routers.PrimaryReplicaRouter"]
8–9
Random choice across replicas is the spread Django's own example uses. Anything cleverer runs on every read, so keep it to arithmetic rather than a health check.
14–20
The method most often omitted. Returning `None` here means "no opinion", and Django then refuses relations between objects from different aliases — which surfaces later as a confusing error in unrelated code.
22–24
One line that makes an entire category of accident impossible. A `migrate` aimed at a replica either fails on a read-only server or, if the replica is writable, creates a divergence replication will conflict with.
29
Leaving `default` empty forces every query to be routed or explicitly aliased. A populated `default` hides router mistakes, because anything unrouted silently works.
31–32
`TEST: {"MIRROR": "primary"}` tells the test runner these are replicas of the same data rather than separate databases, so it does not create and migrate a second test database for each.

Why this works: All four methods are present, the replica aliases are declared as mirrors for tests, and an empty `default` turns a silent routing gap into an immediate error.

Writing only `db_for_read` and `db_for_write`

Wrong

python
class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):  return random.choice(REPLICAS)
    def db_for_write(self, model, **hints): return "primary"
    # no allow_relation, no allow_migrate

Better

python
    def allow_relation(self, obj1, obj2, **hints):
        return True if {obj1._state.db, obj2._state.db} <= ALL else None

    def allow_migrate(self, db, app_label, model_name=None, **hints):
        return db == "primary"

What you see: Two unrelated failures appear: an error about relating objects from different databases in code that never mentions databases, and a deploy that tries to run migrations against a replica.

Why: Django treats the four methods as independent questions and applies conservative defaults to the two you did not answer. For relations, the default is to refuse anything spanning aliases — correct for genuinely separate databases and wrong for a primary and its replicas, which hold the same rows. For migrations, the absence of an opinion means "go ahead", so every alias in `DATABASES` is a migration target. Both are one-line answers, and both prevent failures that surface far from the router.

One request, two databases — and the query that must not be routed
view
router
primary
replica
  1. 1. SELECT Order (list page)
  2. 2. db_for_read → "replica1"read traffic leaves the primary alone
  3. 3. rows
  4. 4. INSERT Order
  5. 5. db_for_write → "primary"always; a replica is read-only
  6. 6. replication streamasynchronous — this is where lag lives
  7. 7. SELECT the order just writtenrouted to the replica by default
  8. 8. DoesNotExistnot an error in the router — the row has not arrived yet
  9. 9. .using("primary") for read-your-writesthe override the router cannot infer
  1. view → router: SELECT Order (list page)
  2. router → replica: db_for_read → "replica1" (read traffic leaves the primary alone)
  3. replica → view: rows
  4. view → router: INSERT Order
  5. router → primary: db_for_write → "primary" (always; a replica is read-only)
  6. primary → replica: replication stream (asynchronous — this is where lag lives)
  7. view → replica: SELECT the order just written (routed to the replica by default)
  8. replica → view: DoesNotExist (not an error in the router — the row has not arrived yet)
  9. view → primary: .using("primary") for read-your-writes (the override the router cannot infer)

The four router methods, and what happens if you skip one

The four router methods, and what happens if you skip one
MethodAnswersSkipping it causes
`db_for_read`which database serves this SELECTevery read hits the primary — no benefit at all
`db_for_write`which database takes this INSERT/UPDATEwrites may be sent to a read-only replica and fail
`allow_relation`may these two objects be related?cross-database relation errors when mixing replica reads with primary writes
`allow_migrate`may this migration run here?`migrate` attempts to alter a replica

Together

python
DATABASE_ROUTERS = ["shop.routers.PrimaryReplicaRouter"]
Order.objects.using("primary").get(pk=pk)   # override, per call

Remember: A router answers four independent questions, and the two people skip are the ones that fail far away: `allow_relation` must return `True` across primary and replicas or Django refuses to relate objects read from different aliases, and `allow_migrate` should permit only the primary so `migrate` can never touch a replica. Keep the router to arithmetic — it runs on every query, so a health check inside it is multiplied by your query rate. Routing is a default, not a rule: `using("primary")` is how a read that must see a recent write opts out.

See also: replica lag and reading your own writes · worker multiplication and connection exhaustion · connection limits replicas and partitioning

Advertisement

Lag, and the reads that must not be routed

Read-your-own-writes, decision reads, and why lag is a data-loss number during a failover.

Replica lag, and the reads that must go to the primary

coreadvanced

Replication is asynchronous, so a row written to the primary takes some time to appear on a replica — usually milliseconds, sometimes much longer under load or during a large write. In that window a read routed to the replica does not see the write, which is what the roadmap means by "a newly created record might not be visible immediately". The classic symptom is a user saving a form, being redirected, and seeing their old data or a 404. Django is explicit that it "doesn't provide any solution for handling replication lag" — deciding which reads must go to the primary is your job.

Think of it as

Sort every read into one of two boxes: reads that may be slightly stale, and reads that may not. Most reads are in the first box, and that is what makes replicas worth having — a dashboard, a search page, a list of past orders can all be a second behind and nobody can tell. The second box is small but sharply defined, and it has two members. First, anything the same user is about to see immediately after their own write: the redirect after a save, the detail page after a create, the balance after a payment. Being stale here is not a subtle inconsistency, it is the user watching their action fail. Second, anything a decision is made on: a stock check before reserving, a balance check before a withdrawal, a permission check before an action. Reading those from a replica means deciding against data you already know is out of date, and the write that follows will be based on it. The useful pattern for the first case is sticky reads — after a user writes, route their reads to the primary for a short window, long enough to cover normal lag. The second case is simpler: those reads always name the primary. Then there is failover, which is where lag stops being a latency question and becomes a durability one. When a primary fails and a replica is promoted, any transaction that had committed on the old primary but not yet reached the promoted replica is gone. Applications feel this as writes that succeeded and then vanished, and no amount of application code can recover them — which is why the acceptable lag under load is an operational number worth knowing rather than an implementation detail.

python
# after a write, pin this user's reads to the primary for a short window
request.session["read_primary_until"] = time.time() + 5

What we're doing: Implement sticky reads so a user always sees their own write, without sending all traffic to the primary.

shop/routers.pypython
import threading

_state = threading.local()
STICKY_SECONDS = 5


class StickyPrimaryMiddleware:
    """Marks this request as 'recently wrote' so reads stay on the primary."""

    def __call__(self, request):
        until = request.session.get("read_primary_until", 0)
        _state.read_primary = time.time() < until

        response = self.get_response(request)

        if request.method in ("POST", "PUT", "PATCH", "DELETE"):
            request.session["read_primary_until"] = time.time() + STICKY_SECONDS

        _state.read_primary = False
        return response


class PrimaryReplicaRouter:
    def db_for_read(self, model, **hints):
        if getattr(_state, "read_primary", False):
            return "primary"                  # this user wrote recently
        if model._meta.label in ALWAYS_PRIMARY:
            return "primary"                  # decisions are never made on stale rows
        return random.choice(REPLICAS)

    def db_for_write(self, model, **hints):
        return "primary"


ALWAYS_PRIMARY = {"shop.Stock", "billing.Balance", "accounts.Permission"}


def reserve_stock(sku, quantity):
    """A decision read plus its write, both on the primary, in one transaction."""
    with transaction.atomic(using="primary"):
        stock = Stock.objects.using("primary").select_for_update().get(sku=sku)
        if stock.remaining < quantity:
            raise OutOfStock(sku)
        stock.remaining -= quantity
        stock.save(using="primary")
10–12
The flag is read from the session at the start of the request and put somewhere the router can see it. Thread-local state is the usual carrier, because the router receives no request object.
16–17
Any unsafe method starts the sticky window. Five seconds comfortably covers normal lag; if yours is routinely larger, that is an operational problem rather than a number to raise.
22–26
Two independent reasons to use the primary: this user wrote recently, or this model is one whose rows drive decisions. Everything else spreads across replicas.
34
The list is explicit and short. Naming the models makes the policy reviewable, and a new model is stale-tolerant by default rather than by omission.
37–44
The decision case in full: the read, the check and the write are all on the primary inside one transaction with a row lock. Reading this from a replica would mean deciding on data known to be out of date.

Why this works: Read traffic still spreads across replicas, while the two categories that cannot tolerate staleness — a user's own recent write, and any row a decision depends on — are handled by rules rather than by hoping the lag stays small.

Reading a row you just wrote through the router

Wrong

python
order = Order.objects.create(customer=customer, total=total)   # primary
return redirect("order-detail", pk=order.pk)

def order_detail(request, pk):
    order = get_object_or_404(Order, pk=pk)     # routed to a REPLICA
    ...                                          # 404, intermittently

Better

python
def order_detail(request, pk):
    qs = Order.objects.all()
    if recently_wrote(request):
        qs = qs.using("primary")
    order = get_object_or_404(qs, pk=pk)

What you see: A 404 immediately after creating something, which disappears on refresh — so it is reported as flaky, cannot be reproduced locally where there is no replica, and passes every test.

Why: The write and the read go to different databases, and replication has not necessarily closed the gap in the microseconds between them. The application is not wrong anywhere it can see: the write succeeded, the read succeeded, and the row genuinely does not exist on the replica yet. Because it depends on timing, it is worst under load and absent in development. Sticky reads fix it generally; a targeted `using("primary")` fixes the specific path. Local reproduction is possible by pointing the replica alias at a deliberately delayed copy.

The lag window, from the user's point of view
  1. t+0 ms

    POST /orders/ — the write commits on the primary

    the user is told it worked, and it did

  2. t+1 ms

    Redirect to /orders/8412/

    the browser follows immediately — faster than replication

  3. t+3 ms

    The router sends the read to a replica

    no error anywhere: this is the configured behaviour

  4. t+3 ms

    404 — the row has not arrived

    the user sees their order vanish one millisecond after creating it

  5. t+40 ms

    Replication catches up

    a refresh now works, which is why the bug is reported as "intermittent"

  6. under load

    The window widens to seconds

    a bulk import or a long transaction on the primary is enough

  7. on failover

    The window becomes data loss

    a promoted replica never receives what had not arrived yet

  1. t+0 ms: POST /orders/ — the write commits on the primary — the user is told it worked, and it did
  2. t+1 ms: Redirect to /orders/8412/ — the browser follows immediately — faster than replication
  3. t+3 ms: The router sends the read to a replica — no error anywhere: this is the configured behaviour
  4. t+3 ms: 404 — the row has not arrived — the user sees their order vanish one millisecond after creating it
  5. t+40 ms: Replication catches up — a refresh now works, which is why the bug is reported as "intermittent"
  6. under load: The window widens to seconds — a bulk import or a long transaction on the primary is enough
  7. on failover: The window becomes data loss — a promoted replica never receives what had not arrived yet

Where each read belongs

Where each read belongs
ReadRoute toWhy
a dashboard, a report, a search pagereplicaa second of staleness is invisible
the redirect after the user's own saveprimary (sticky window)otherwise their change appears not to have happened
stock before reserving itprimarya decision made on stale data produces a wrong write
a balance before a withdrawalprimarysame — and the error is financial
a permission check before an actionprimarya revoked permission must not survive on a replica
a background report over yesterdayreplicastale by design, and the heaviest query you have

Together

python
Order.objects.using("primary").get(pk=pk)      # after this user just wrote it

Remember: Replication is asynchronous, so a read routed to a replica can miss a write that already succeeded — Django says outright that it provides no solution for lag, which makes the routing policy yours. Two categories must not be routed: a user's own recent write (handled with a short sticky window after any unsafe method) and any row a *decision* depends on, such as stock, balance or permissions, which always reads from the primary inside the transaction that will write. And treat lag as a durability number, not a latency one: on failover, whatever had not replicated is lost, so the size of the window is the size of the potential data loss.

See also: read write splitting with a database router · the signals worth measuring · recognizing the pattern

Advertisement