Routing models to multiple databases with session binds

Pass binds={Product: catalogue_engine, Invoice: billing_engine} to async_sessionmaker and the session routes each statement by the model it targets — but a commit then commits each engine's transaction separately, so a unit of work that must be atomic has to stay inside one database. This guide belongs to dynamic schema and multi-tenant routing.

Quick Answer

A session can be bound to more than one engine, keyed by the models each engine owns.

Choose the session, or let the mapping choose Left: one factory per database and the caller picks, which is explicit and means every call site knows which database its models live in. Right: one session with a binds mapping from model to engine, so a query for any model is routed automatically and a single unit of work can touch both databases. a factory per database CatalogueSession / BillingSession the caller chooses no cross-database unit of work obvious at every call site one session, binds by model binds={Product: catalogue, Invoice: billing} routing is automatic one session spans both and one commit is not atomic The convenience is real; the atomicity it appears to offer is not.

Before — a session bound to one engine cannot see the other database:

from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

from shop.models import Invoice, Product

catalogue = create_async_engine("postgresql+asyncpg://shop:secret@catalogue/shop")
Session = async_sessionmaker(catalogue, expire_on_commit=False)

async with Session() as session:
    await session.execute(select(Invoice))
# sqlalchemy.exc.ProgrammingError: (asyncpg.exceptions.UndefinedTableError)
# relation "invoices" does not exist

After — one session, routed by model:

from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

from shop.models import Invoice, Product

catalogue = create_async_engine("postgresql+asyncpg://shop:secret@catalogue/shop", pool_size=10)
billing = create_async_engine("postgresql+asyncpg://shop:secret@billing/shop", pool_size=5)

Session = async_sessionmaker(
    binds={Product: catalogue, Invoice: billing},
    expire_on_commit=False,
)

async with Session() as session:
    products = (await session.scalars(select(Product))).all()   # catalogue
    invoices = (await session.scalars(select(Invoice))).all()    # billing

binds accepts mapped classes, Table objects, or a common base class — {CatalogueBase: catalogue, BillingBase: billing} routes whole model hierarchies, which is usually cleaner than listing every class.

What this does not give you is a transaction across both databases. await session.commit() commits the catalogue transaction and then the billing one, and the second can fail after the first has succeeded.

Execution Context & Async Workflow Integration

When a session executes a statement, it determines which mapper the statement targets and asks get_bind() which engine to use. The binds mapping is the declarative way to answer: SQLAlchemy looks up the mapper, then its base classes, then the table, and uses the first entry it finds. A bind= engine passed alongside acts as the fallback for anything unmapped — raw text() statements, for instance, which have no mapper to route by.

Routing, and the commit that is not atomic Five steps. The session receives a statement and determines which mapper it targets. It looks that mapper up in the binds mapping to find the engine. A connection is checked out from that engine and the statement runs in its own transaction. At commit, the session commits each engine transaction in turn. If the second commit fails, the first has already committed — there is no distributed transaction. session.execute(select(Invoice)) the mapper is identified from the statement binds lookup Invoice → billing engine per mapper or per table a connection per engine a transaction per engine independent commit: one engine, then the other not atomic the first may commit and the second fail design for it idempotency, or an outbox Two databases, two transactions. Anything that must be atomic has to live in one of them.

Each engine the session touches gets its own connection and its own transaction, begun lazily on first use. That is what makes routing work, and also what limits it: PostgreSQL has no distributed transaction across two servers that SQLAlchemy uses by default, so a session spanning two engines is a session with two transactions.

commit() therefore commits them in sequence. If the second fails — a constraint violation, a lost connection, a failover — the first is already durable. There is no rollback for it, and SQLAlchemy does not pretend otherwise. Two-phase commit exists (twophase=True with a suitable backend and max_prepared_transactions configured), and it is rarely worth the operational cost: prepared transactions that are never resolved hold locks and block vacuum indefinitely.

The practical rule follows: every unit of work should write to at most one database. Cross-database reads in one session are fine and useful. Cross-database writes need a design that tolerates partial failure — an idempotency key so the second attempt is safe, or an outbox table in the first database that a worker reads and applies to the second.

For read-replica routing, the more flexible hook is get_bind() on a Session subclass, because the decision depends on the statement rather than on the model:

from sqlalchemy.orm import Session


class RoutingSession(Session):
    def get_bind(self, mapper=None, clause=None, **kwargs):
        if self._flushing or self.info.get("wrote"):
            return primary_engine            # read your own writes
        return replica_engine

self._flushing is true while the session is writing, so flushes always go to the primary. Setting self.info["wrote"] = True on the first write — from an after_flush event — keeps the rest of that session's reads on the primary too, which is what prevents the "your order could not be found" failure. The full replica-routing treatment, including lag measurement, is in routing reads to replicas with async engines.

Using a custom Session class with async is one line: async_sessionmaker(sync_session_class=RoutingSession), because AsyncSession wraps a synchronous Session and that is the class to customise.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
UnboundExecutionError: Could not locate a bind configured on mapper ... or this SessionThe model is not in binds and no fallback bind was given.Add it to binds, or pass bind= as the default.
UndefinedTableError: relation "invoices" does not existThe statement was routed to the wrong engine.Check the binds keys — a base class entry may be shadowing a subclass.
A text() statement goes to the wrong databaseRaw SQL has no mapper, so it uses the fallback bind.session.execute(text(...), bind_arguments={"bind": billing}).
Half the work committed after an errorTwo engines, two transactions, committed in sequence.Keep writes in one database; use an outbox for the rest.
A row written and then not found in the same requestReads routed to a replica with lag.Read your own writes: pin the session to the primary after a write.
Table 'products' is already defined for this MetaDataTwo model hierarchies sharing one MetaData.A MetaData per database, and a declarative base per MetaData.
Migrations affect only one databaseAlembic runs against one URL at a time.A revision directory per database, or the multidb template.
Four reasons to use binds Four tiles. A read replica for reporting models, so heavy reads go elsewhere. A separate database owned by another team that this service reads. A legacy database being migrated away from, where both are live during the transition. And a sharded layout where a session is bound to one shard per request. a read replica for reports reads routed away lag is acceptable another team’s database read-only models no writes to theirs a legacy database in transition both live for a while one release at a time a shard per request bound at session creation one shard per unit of work In all four, each unit of work writes to at most one database — which is the rule that makes it safe.

The MetaData point is worth acting on before the first model is written, because it makes everything else clearer. One base per database means the binds mapping is two entries, table names cannot collide, and Alembic's target_metadata is unambiguous:

from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase


class CatalogueBase(DeclarativeBase):
    metadata = MetaData(schema=None, naming_convention=NAMING_CONVENTION)


class BillingBase(DeclarativeBase):
    metadata = MetaData(schema=None, naming_convention=NAMING_CONVENTION)


Session = async_sessionmaker(
    binds={CatalogueBase: catalogue, BillingBase: billing},
    expire_on_commit=False,
)

Migrations then need one Alembic environment per base, which running Alembic across multiple databases and tenant schemas covers — a separate revision directory per database keeps each history linear and reviewable.

One more limitation to know: relationships cannot span engines. A ForeignKey from a catalogue table to a billing table is not enforceable, and relationship() between models on different engines cannot emit a join. Cross-database references are carried as plain id columns, with the lookup done in the application — which is a real loss of integrity and a good reason to be sure two databases are genuinely necessary.

Advanced: Cross-Database Writes Without Distributed Transactions

When a workflow genuinely has to write to two databases, the durable answer is an outbox: the first database records the intent in the same transaction as the work, and a worker delivers it to the second.

Three ways to route a read Three options. A separate session factory per engine, chosen at the call site, which is explicit and impossible to get wrong by accident. Binds by model, which suits a schema where whole models live elsewhere. And a get_bind override that inspects the statement, which can route by operation — reads to a replica, writes to the primary — at the cost of behaviour no call site shows. a factory per engine, chosen by the caller explicit; a reporting endpoint uses ReaderSession and says so binds by model right when whole models genuinely live in another database a get_bind override by operation reads to a replica, writes to the primary — powerful, and invisible at the call site
import datetime as dt

from sqlalchemy import JSON, DateTime, String, select, update
from sqlalchemy.orm import Mapped, mapped_column

from shop.models import CatalogueBase


class OutboxEvent(CatalogueBase):
    __tablename__ = "outbox_events"

    id: Mapped[int] = mapped_column(primary_key=True)
    topic: Mapped[str] = mapped_column(String(64))
    payload: Mapped[dict] = mapped_column(JSON)
    created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True))
    delivered_at: Mapped[dt.datetime | None] = mapped_column(
        DateTime(timezone=True), default=None, index=True
    )

The write path stays inside one database and one transaction:

async def publish_product(session, product: Product) -> None:
    async with session.begin():                     # catalogue only
        session.add(product)
        session.add(OutboxEvent(
            topic="product.published",
            payload={"sku": product.sku, "price_cents": product.price_cents},
            created_at=dt.datetime.now(dt.UTC),
        ))

Either both rows commit or neither does, because they are in the same database. A worker then reads undelivered events and applies them to the billing database, marking each delivered in its own transaction:

async def deliver_outbox(CatalogueSession, BillingSession, batch: int = 100) -> int:
    async with CatalogueSession() as read:
        events = list((await read.scalars(
            select(OutboxEvent)
            .where(OutboxEvent.delivered_at.is_(None))
            .order_by(OutboxEvent.id)
            .limit(batch)
            .with_for_update(skip_locked=True)
        )))
        if not events:
            return 0
        async with BillingSession.begin() as write:
            for event in events:
                await apply_to_billing(write, event)      # idempotent by event id
        await read.execute(
            update(OutboxEvent)
            .where(OutboxEvent.id.in_([e.id for e in events]))
            .values(delivered_at=dt.datetime.now(dt.UTC))
        )
        await read.commit()
    return len(events)

The delivery is at-least-once: a crash between the billing commit and the outbox update re-delivers, which is why apply_to_billing must be idempotent — keyed on the event id, with a unique constraint enforcing it. That is the same guarantee, and the same requirement, as in handling IntegrityError on concurrent inserts.

SKIP LOCKED lets several workers share the outbox without coordination — the job-queue pattern from building a job queue with SELECT FOR UPDATE SKIP LOCKED.

Compared with two-phase commit, the outbox is more code and far less operational risk: no prepared transactions to leak, no coordinator to run, and a failure mode — delayed delivery — that is visible in a table anyone can query.

Deciding Whether Two Databases Are Warranted

Session binds make multiple databases workable, which makes it worth asking whether they are warranted, because the costs are structural rather than incidental.

The lag that breaks a form Left: a request writes to the primary and then reads through the replica, which may not have the row yet, so the user is told the thing they just created does not exist. Right: the same request reads from the primary for the rest of its lifetime, and only genuinely independent reads — reports, dashboards — go to the replica. write, then read the replica INSERT on the primary SELECT on the replica the row is not there yet "your order could not be found" read your own writes once a request writes, it reads the primary replicas serve independent reads reports, dashboards, exports lag becomes acceptable Route by operation and by request history, not only by whether a statement is a SELECT.

What you lose. Foreign keys cannot cross databases, so referential integrity between the two becomes application logic that will eventually be wrong. Joins cannot cross either, so a query that needs both does two round trips and combines in Python. Transactions cannot span them. And every operational concern doubles: two connection pools, two backup schedules, two migration histories, two sets of credentials.

What genuinely justifies it. A database owned by another team, where the boundary is organisational rather than technical. A legacy system being migrated away from, where both are live for a transition. A read replica, which is the same data and therefore none of these problems. Or a hard requirement — a contract that says a customer's data lives in a specific region.

What usually does not. "Separating concerns" between two services that are deployed together and change together; the boundary adds cost without buying independence. Performance, which is almost always better served by indexes, caching or a replica than by splitting the schema. And future-proofing, which pays the cost now for an option that may never be exercised.

For multi-tenancy specifically, the choice between a tenant column, a schema per tenant and a database per tenant is worth making on its own terms, and the trade-offs are laid out in the parent guide on dynamic schema and multi-tenant routing and in enforcing tenant isolation with Postgres row-level security. A database per tenant is the strongest isolation and multiplies every operational task by the tenant count; binds are how a session reaches the right one, not a reason to choose that layout.

If the answer is that two databases are warranted, two habits keep the cost contained. Keep each unit of work inside one database, with an outbox for anything that must reach the other. And make the boundary visible in the code — separate bases, separate modules, separate session factories where the caller should be choosing — so that a developer cannot accidentally write a query that assumes the two are one.

Frequently Asked Questions

How do I bind one session to several engines?

Pass a binds mapping to async_sessionmaker, keyed by mapped class, declarative base or Table, with each value an engine. A bind= engine alongside it serves as the fallback for unmapped statements.

Is a commit across two engines atomic?

No. The session commits each engine's transaction in turn, so the second can fail after the first has committed. Keep writes within one database, and use an outbox table for cross-database effects.

How do I route reads to a replica?

Either a separate session factory the caller chooses, or a get_bind() override on a Session subclass that returns the primary while flushing and after any write in that session, and the replica otherwise.

Can a relationship span two databases?

No. Foreign keys and joins cannot cross databases. Carry the reference as a plain id column and resolve it in the application, accepting that integrity is no longer enforced.