Route Django Reads to PostgreSQL Read Replicas

Add PostgreSQL read replicas to Django: multi-database settings, routers, replication lag and read-your-writes, transactions, migrations, and tests.

Problem statement

When a Django application's database becomes the bottleneck and most of the load is reads (list pages, search, dashboards, reports), a read replica can take a large share of that work. Managed PostgreSQL services make creating replicas a few clicks. Using them correctly from Django takes more thought:

  • Django sends every query to the default database unless told otherwise
  • replicas lag behind the primary, so a user who just saved a form may not see their change
  • queries inside a transaction must go to the primary to be consistent
  • migrations must never run against a replica
  • tests need the replica alias to behave sensibly without a real replica

This guide adds a replica to DATABASES, routes reads with a database router, handles replication lag for read-your-writes, and verifies the setup.

Splitting reads and writesWrite queryprimaryReplicationstreaming lagRead queryreplicaRouterdecides per queryReplica reads can be stale, so route anything read-after-write to the primary.

Quick answer

  1. First exhaust cheaper options: fix N+1 queries, add indexes, cache hot reads.
  2. Add the replica as a second alias in DATABASES, with TEST["MIRROR"] = "default".
  3. Write a database router: reads go to the replica, writes and migrations to default, relations allowed between both.
  4. Keep reads on the primary for a short window after a user writes (read-your-writes), and inside transactions.
  5. Use .using("default") for reads that must be fresh, such as uniqueness checks before writes.
  6. Monitor replication lag and alert when it grows.
  7. Verify which database each query uses in staging.

Step-by-step solution

1. Confirm replicas are the right tool

Read replicas help when:

  • database CPU or I/O is high, and most query time is SELECT
  • expensive reporting or analytics queries compete with user traffic
  • you have already optimized queries and indexes

They do not help with write-heavy workloads, lock contention on the primary, or slow queries that would be equally slow on a replica. Check pg_stat_statements to see where time is spent before adding infrastructure.

2. Configure DATABASES

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_PRIMARY_HOST"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_USER"],
        "PASSWORD": os.environ["DB_PASSWORD"],
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
    },
    "replica": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_REPLICA_HOST"],
        "NAME": os.environ["DB_NAME"],
        "USER": os.environ["DB_REPLICA_USER"],
        "PASSWORD": os.environ["DB_REPLICA_PASSWORD"],
        "CONN_MAX_AGE": 60,
        "CONN_HEALTH_CHECKS": True,
        "TEST": {"MIRROR": "default"},
    },
}

DATABASE_ROUTERS = ["core.db_routers.PrimaryReplicaRouter"]
  • Use a read-only database role for the replica alias when possible. Replicas reject writes anyway, but a read-only role makes the intent explicit and protects against pointing the alias at a primary by mistake.
  • TEST["MIRROR"] = "default" makes the test runner treat replica as a mirror of default, so tests do not need a real replica.
  • Every Django process now holds connections to both databases; include them in connection limits.

3. Write the router

# core/db_routers.py
import random

from core.db_pinning import _pinned_to_primary

REPLICAS = ["replica"]


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

    def db_for_write(self, model, **hints):
        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"
the router, method by methoddef db_for_read(self, model, **hints):return"replica"def db_for_write(self, model, **hints):return"default"def allow_migrate(self, db, app_label, **hints):returndb == "default"1231Every plain read goes to the replica.2Writes always reach the primary.3Never migrate the replica; it follows the primary.Reads inside atomic() or select_for_update() must use the primarydjango-deployment.com
allow_relation should return True, since both aliases hold the same data.
  • db_for_read sends reads to a replica unless the current request is pinned to the primary (step 4).
  • db_for_write always returns the primary.
  • allow_relation returns True because both aliases contain the same data.
  • allow_migrate limits migrations to the primary; migrate --database=replica does nothing.

4. Handle replication lag: read-your-writes

A user submits a form, Django writes to the primary, redirects, and the next page reads from the replica, which may be a fraction of a second (or much more under load) behind. The user sees old data and assumes the save failed.

the stale read after a writePOST saves profileprimary, t=0Redirectt=20 msGET reads replicat=40 msOld data shownlag 200 msPin to primaryfor a few seconds after writesThe user sees their change disappear, then reappeardjango-deployment.com
A cookie or session flag set on write is a simple way to pin reads.

Pin a user's reads to the primary for a short time after they write. A simple approach uses a cookie set when a request performs writes, plus request-scoped state read by the router:

# core/db_pinning.py
from contextvars import ContextVar

_pin_primary = ContextVar("pin_primary", default=False)
PIN_COOKIE = "pin_primary"
PIN_SECONDS = 10
UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}


def _pinned_to_primary():
    return _pin_primary.get()


class PinPrimaryMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        pinned = request.method in UNSAFE_METHODS or PIN_COOKIE in request.COOKIES
        token = _pin_primary.set(pinned)
        try:
            response = self.get_response(request)
        finally:
            _pin_primary.reset(token)
        if request.method in UNSAFE_METHODS:
            response.set_cookie(PIN_COOKIE, "1", max_age=PIN_SECONDS, httponly=True, samesite="Lax", secure=True)
        return response

The router imports _pinned_to_primary from this module. Add the middleware near the top of MIDDLEWARE. Choose PIN_SECONDS well above typical replication lag.

ContextVar works for both sync and async request handling, unlike plain thread-local storage.

5. Transactions and consistency-critical reads

Within transaction.atomic(), the router still decides per query. A read routed to the replica inside a transaction does not see the uncommitted writes of that transaction and may return stale data. Pin transactional code paths explicitly:

from django.db import transaction


def reserve_stock(product_id, quantity):
    with transaction.atomic(using="default"):
        product = (
            Product.objects.using("default")
            .select_for_update()
            .get(pk=product_id)
        )
        if product.stock < quantity:
            raise OutOfStock(product_id)
        product.stock -= quantity
        product.save(using="default")

Also use the primary for:

  • uniqueness checks before creating objects
  • authentication-related reads right after signup or password changes
  • anything feeding a write decision (balances, inventory, permissions)

Django's ModelForm validation of unique fields runs queries through the router; for forms where a stale read could let duplicates through, rely on database unique constraints, which the primary enforces.

6. Send heavy reports to replicas explicitly

Reporting code benefits from explicit routing, independent of the router's pinning:

from django.db.models import Count, Sum


def monthly_sales_report(year, month):
    return (
        Order.objects.using("replica")
        .filter(created_at__year=year, created_at__month=month)
        .values("customer__country")
        .annotate(orders=Count("id"), revenue=Sum("total"))
        .order_by("-revenue")
    )
where each query should runDatabaseOrdinary page readsโœ“replicaWritesprimaryReads inside a transactionprimaryHeavy reportsโœ“replica, via .using()Migrationsprimary onlyWhen in doubt about consistency, read from the primarydjango-deployment.com
Reports on a replica cannot slow down writes on the primary.

Long-running queries on a replica can conflict with replication (PostgreSQL may cancel them with "canceling statement due to conflict with recovery" when max_standby_streaming_delay is exceeded). For very long analytics queries, a dedicated replica with more tolerant settings, or a separate analytics database, works better.

7. Admin, migrations, and management commands

  • The admin works with the router; after saving, the pinning cookie keeps the change list fresh.
  • migrate only applies to default because of allow_migrate.
  • Management commands that read then write in bulk should use .using("default") for reads to avoid acting on stale data.

8. Monitor replication lag

On the replica:

SELECT now() - pg_last_xact_replay_timestamp() AS replay_delay;

On the primary:

SELECT client_addr, state, write_lag, flush_lag, replay_lag
FROM pg_stat_replication;

pg_last_xact_replay_timestamp() only advances when there are transactions to replay, so on an idle primary the delay appears to grow; interpret it together with write activity. Managed services expose a replica lag metric; alert when it exceeds your pinning window.

If lag grows too large, you can make the router fall back to the primary automatically by checking a cached lag value periodically, at the cost of more primary load during replica trouble.

9. Verify

In staging, log which database each query uses:

LOGGING["loggers"]["django.db.backends"] = {
    "handlers": ["console"],
    "level": "DEBUG",
}

With DEBUG = True in staging only, query logs include the database alias (using). Also check from PostgreSQL:

-- on the replica
SELECT count(*) FROM pg_stat_activity WHERE usename = 'app_readonly';

Verification check:

  • list and detail pages query the replica for anonymous traffic
  • a form submission followed by a redirect shows the new data immediately
  • migrate does not attempt to run on the replica
  • tests pass with TEST["MIRROR"]
  • primary CPU decreases while replica CPU increases under the same traffic

Explanation

A read replica copies the primary's changes asynchronously, so it can answer read queries but always slightly in the past. Django's database routers let you decide per query where it runs. Sending ordinary reads to replicas and everything that changes data, or depends on fresh data, to the primary gives most of the scalability benefit. The pinning window after writes hides replication lag from the users who would notice it: the ones who just changed something.

Edge cases / notes

Multiple replicas

Add aliases to REPLICAS. Random choice spreads load; weighted or health-aware selection helps when replicas differ in size or state.

Connection pooling

Poolers such as PgBouncer can sit in front of both the primary and replicas. Some managed services provide a reader endpoint that load-balances across replicas; point the replica alias at it.

Failover

After a primary failover, the old replica may become the primary. Use the provider's stable endpoints for both roles rather than instance hostnames.

Common mistakes

  • Adding replicas before fixing query inefficiencies.
  • Reading from replicas inside transactions that write.
  • No read-your-writes handling, causing "my changes did not save" reports.
  • Forgetting allow_migrate, leading to migration attempts on the replica alias.

Next steps

FAQ

How do I use a read replica with Django?

Add the replica as a second database alias and configure a database router whose db_for_read returns the replica and whose db_for_write and allow_migrate use the primary.

Why don't users see their changes when using a Django read replica?

Replication is asynchronous, so the replica lags behind the primary. Route a user's reads to the primary for a short period after they write, and use the primary for consistency-critical reads.

Should reads inside transaction.atomic go to the replica?

No. A replica does not see the transaction's uncommitted writes. Use using("default") for queries inside transactions that also write.

How do I test Django code with a replica database alias?

Set TEST["MIRROR"] = "default" for the replica alias. The test runner then treats it as a mirror of the default database, without a real replica.

Keep going