Skip to content

Several databases

An application with several connections picks one per query: by using= at the call, or by a router that decides for every query of a model, reads and writes apart.

using=

A connection is chosen the same way everywhere — by one name, using, which takes a connection alias ("replica") or a client (a transaction's, one from autonomous(), a router, or Connections.get(connection_alias)):

On a queryset: .using(alias_or_client) — a copy of the queryset bound to that connection. Every query starts from one, so this covers create, get, get_or_create, update_or_create, filter, bulk_create, bulk_update, raw, count, ...: Book.objects.using("replica").filter(...), Book.objects.using(transaction).create(...).

On an instance: the using= argument of save, delete, hard_delete, delete_preview, restore and refresh_from_db; of the relation writes (add, remove, set, clear, create, ...), a many-to-many relation's included; and of prefetch_related_objects().

Elsewhere: Transactions.atomic(using), on_commit(..., using=), on_rollback(..., using=), execute_sql(..., using=). A client of an autonomous transaction is passed the same way:

async with Transactions.autonomous() as connection:
    book = await Book.objects.using(connection).create(title="Notes")
    await book.tags.add(tag, using=connection)

Instances remember their connection

A model instance remembers the connection alias it was loaded from (including instances built by select_related()/prefetch_related(), stream(), union() and raw()) or last saved to (save(), create(), get_or_create(), bulk_create()). Every later operation on it that isn't given using= goes there by default: save(), delete(), restore(), refresh_from_db(), prefetch_related_objects(), awaiting a forward relation (await book.author), and the relations of the instance (author.books.all()/.filter()/.count()/.values(), M2M add()/remove()/clear()):

book = await Book.objects.all().using("replica").get(title="Notes")
author = await book.author          # read from "replica", not from "default"
books = await author.books.all()    # "replica" too

The alias is looked up when the query runs, so inside an open atomic() on that same alias the transaction is used. An explicit using=/.using() always wins, and so does a router that has an opinion for the queried model. A related model whose default connection differs from the instance model's (a cross-database relation), or a relation read from an instance whose model the router routes, keeps its own connection choice. The queryset of a Prefetch("books", Book.objects.filter(...)) reads on the parent's connection as the plain prefetch_related("books") does, unless it chose one with .using() itself.

Routers

Write a plain class — no base class to inherit, hare dispatches to it duck-typed — with either or both methods, each returning a connection alias string (or None):

class ReportingRouter:
    def db_for_read(self, model: type[Model]) -> str | None:
        if model._meta.app == "reporting":
            return "reporting_replica"
        return None

    def db_for_write(self, model: type[Model]) -> str | None: ...

Either method may be absent entirely — hare skips a router for an action it doesn't implement, rather than erroring. The first configured router to return a non-None alias wins; if none do, the model's usual default connection applies. A returned alias that doesn't resolve to a configured connection raises ConfigurationError — a router matching but naming a typo'd/stale alias is treated as a real misconfiguration, not silently ignored.

Reads that decide a write run on the db_for_write connection: the existence check of get_or_create(), the locked read of update_or_create() and select_for_update(). A read replica may not have a row the primary already holds (replication lag, or a row created earlier in a still-open transaction), and a miss there would create a duplicate.

Register via Hare.init(routers=[...]) — a list of dotted-path strings ("library.routers.ReportingRouter") or classes directly, each instantiated without arguments. Each HareContext holds its routers in a ConnectionRouter of its own (context.router), which asks them in turn — so the routers of different contexts never mix.

A router chooses where queries go, not where tables are: makemigrations, migrate and drift work on the default_connection of each app and never ask a router. So a router routes between connections holding the same tables — a primary and its replicas; a model whose table lives in another database belongs to an app whose default_connection is that database. A router sending a model to a connection without its table makes its queries fail there (no such table).

Reading your own writes

A replica gets a write later than the primary — a request that lends a book and reads the loan back at once may not find it there. So once an asyncio task (a request) has written through a connection, every read of a model db_for_write sends to that connection goes to it rather than to the connection db_for_read returns, for the rest of the task. Another task — another request — keeps reading from the replica. A task started from the writing one sees its writes too; its own writes stay with it — the task that started it reads as before. An open Transactions.atomic("primary") is no write: a read before the block's first write goes where db_for_read sends it — the replica, not the transaction. A transaction reading the rows it is about to change reads them with .using("primary") or inside Routing.using_primary(), which take the open transaction.

A write is what changes rows: save(), delete() and restore() of an instance, a queryset's create(), update(), delete(), bulk_create() and bulk_update(), a many-to-many add(), remove(), clear() and set() — through the connection the router chose or .using() named, or the model's default_connection where db_for_write returns None. Asking which connection a model is written through (Model.get_connection(for_write=True)) writes nothing and changes no read.

The config's read_your_writes_seconds ends that this long after the task's last write, when the replica has caught up:

await Hare.init(
    config={**HARE_CONFIG, "routers": ["library.routers.PrimaryReplicaRouter"], "read_your_writes_seconds": 5}
)

Routing.using_primary() (a with or an async with block) sends every read of its block to the connection the model is written through, whether the task wrote or not — for a check right after a write made elsewhere:

from hare.core.routing import Routing

async with Routing.using_primary():
    loan = await Loan.objects.get(pk=loan_id)

Routing.forget_writes() lets the current task read from the replicas again — for a task that serves several requests one after another. A queryset's .using() and a transaction choose their connection themselves and aren't affected. Without routers nothing of this runs.