Read/Write Splitting in SQLAlchemy with Multiple Binds
Problem statement: Your SQLAlchemy application sends every query — including 90% read-only SELECTs — to the primary, and you want the ORM itself to send flushes and DML to a writer engine while SELECTs fan out to a read replica, without rewriting every call site.
Symptom Identification
These are the signals that push a team toward multiple-bind routing in SQLAlchemy 2.x:
pg_stat_activityon the primary shows a flood ofSELECTstatements from your application role, while the replica sits nearly idle — the ORM has one engine and no split.- Read-heavy endpoints saturate the primary’s connection pool (
QueuePool limit of size X overflow Y reached) even though a replica is provisioned and healthy. - A naive fix — pointing the whole app at the replica — produces
ERROR: cannot execute INSERT in a read-only transactionthe moment a write path runs. - After adding a second engine by hand at some call sites, you get inconsistent results because developers forget to pick the right engine, and code review cannot catch a missing
bind=. - Read-your-writes bugs appear: a POST creates a row, the follow-up GET reads a replica that has not yet applied the WAL, and the user sees “not found.”
Root Cause Analysis
SQLAlchemy’s Session acquires a connection lazily through Session.get_bind(). By default that method returns the single engine the Session was configured with, so there is no routing layer at all — every statement, read or write, uses the same Engine and therefore the same database. To split traffic you must override get_bind() so it inspects what the Session is about to do and returns a different Engine accordingly.
Two facts drive a correct override. First, during the unit-of-work flush the Session sets an internal flag, self._flushing, to True; any query issued while that flag is set is part of persisting dirty objects and must go to the writer. Second, when you call Session.execute() with a Core Update, Delete, or Insert construct, that construct arrives as the clause argument and can be pattern-matched. Reads — ORM select() loads and Core Select statements — arrive with _flushing unset and a non-DML clause, and are safe to route to a replica.
This is the same in-process routing decision described in the parent guide on ORM middleware for automatic query routing, specialized to SQLAlchemy’s get_bind() hook. The subtlety unique to SQLAlchemy is that a single Session can hold a transaction open on both engines simultaneously, which interacts with autoflush and replication lag in ways covered below.
Architecture: get_bind() as the Split Point
Step-by-Step Resolution
Step 1: Create the writer and reader engines
Build two engines. The writer points at the primary; the reader points at the replica endpoint (a DNS name or load-balancer VIP fronting one or more replicas). Mark the reader read-only so a stray write fails loudly instead of silently hitting a read-only backend.
# db.py
from sqlalchemy import create_engine
writer_engine = create_engine(
"postgresql+psycopg://app:secret@primary.db.internal:5432/app",
pool_size=10, max_overflow=20, pool_pre_ping=True,
)
reader_engine = create_engine(
"postgresql+psycopg://app_ro:secret@replica-lb.internal:5432/app",
pool_size=20, max_overflow=40, pool_pre_ping=True,
# Fail fast if a write is misrouted here
execution_options={"postgresql_readonly": True},
)Inline verification: writer_engine.connect().execute(text("SELECT pg_is_in_recovery()")).scalar() returns False; the same on reader_engine returns True.
Step 2: Subclass Session and override get_bind()
This is the routing core. The official SQLAlchemy pattern checks self._flushing (set during the unit-of-work flush) and the clause type. Add a ContextVar so business logic can force a read onto the writer for read-your-writes.
# routing.py
from contextvars import ContextVar
from sqlalchemy import Update, Delete, Insert
from sqlalchemy.orm import Session
from db import writer_engine, reader_engine
force_writer: ContextVar[bool] = ContextVar("force_writer", default=False)
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, bind=None, **kw):
# Explicit engine passed to execute(bind=...) always wins
if bind is not None:
return bind
# Escape hatch: caller demanded the writer (read-your-writes)
if force_writer.get() or kw.get("realm") == "writer":
return writer_engine
# Persisting dirty objects, or an explicit write construct
if self._flushing or isinstance(clause, (Update, Delete, Insert)):
return writer_engine
return reader_engineself._flushing guarantees that every INSERT/UPDATE/DELETE emitted by the unit of work reaches the writer even though the objects were loaded from the reader. The isinstance check covers Core DML executed directly via session.execute(update(...)).
Inline verification: with writer_engine and reader_engine created with echo=True, a session.execute(select(User)) logs on the reader; a session.add(User(...)); session.flush() logs on the writer.
Step 3: Wire the routing Session into a sessionmaker
Never instantiate RoutingSession directly in application code. Bind it through sessionmaker so pooling, expire_on_commit, and lifecycle are consistent.
# session.py
from sqlalchemy.orm import sessionmaker
from routing import RoutingSession
SessionLocal = sessionmaker(class_=RoutingSession, expire_on_commit=False)Do not pass a bind= to the sessionmaker — get_bind() supplies the engine per statement. Passing a bind would make it the fallback but is unnecessary here.
Inline verification: SessionLocal().get_bind(clause=select(User)) is reader_engine; SessionLocal()._flushing context routes to writer_engine.
Step 4: Add scoped sessions for threaded frameworks
Classic threaded frameworks (Flask, Celery workers) want one Session per thread. scoped_session gives that, and the routing logic rides along unchanged.
# scoped.py
from sqlalchemy.orm import scoped_session
from session import SessionLocal
Session = scoped_session(SessionLocal)
# In a Flask teardown handler:
# @app.teardown_appcontext
# def remove_session(exc=None):
# Session.remove()Session.remove() at the end of each request rolls back and returns connections on both the writer and the reader — critical, because a request that both wrote and read holds an open transaction on each engine.
Inline verification: after Session.remove(), SHOW POOLS-equivalent writer_engine.pool.checkedout() returns to its idle baseline.
Step 5: Route correctly with async engines
For asyncio stacks (FastAPI, async workers), use create_async_engine and async_sessionmaker, and hand your RoutingSession in via sync_session_class. The AsyncSession wraps a synchronous Session, so your get_bind() runs unchanged under the hood.
# async_db.py
from contextvars import ContextVar
from sqlalchemy import Update, Delete, Insert
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
async_force_writer: ContextVar[bool] = ContextVar("async_force_writer", default=False)
async_writer = create_async_engine("postgresql+asyncpg://app@primary.db.internal/app", pool_size=10)
async_reader = create_async_engine("postgresql+asyncpg://app_ro@replica-lb.internal/app", pool_size=20)
class AsyncRoutingSession(Session):
def get_bind(self, mapper=None, clause=None, bind=None, **kw):
if async_force_writer.get():
return async_writer.sync_engine
if self._flushing or isinstance(clause, (Update, Delete, Insert)):
return async_writer.sync_engine
return async_reader.sync_engine
AsyncSessionLocal = async_sessionmaker(
bind=async_writer, sync_session_class=AsyncRoutingSession, expire_on_commit=False,
)Note that get_bind() must return the .sync_engine of each async engine because the wrapped session is synchronous. ContextVar is the correct scoping primitive here — a threading.local() would not follow await boundaries.
Inline verification: await session.execute(select(User)) opens a connection on async_reader; await session.commit() after an add() commits on async_writer. Confirm with application_name on each backend.
Step 6: Confirm the split end to end
Run a mixed workload and inspect both backends.
from sqlalchemy import text
from session import SessionLocal
from routing import force_writer
with SessionLocal() as s:
# Read -> replica
assert s.execute(text("SELECT pg_is_in_recovery()")).scalar() is True
# Write -> primary (flush routes here)
s.add(User(email="a@b.com")); s.flush()
# Force a read-your-writes read onto the primary
tok = force_writer.set(True)
assert s.execute(text("SELECT pg_is_in_recovery()")).scalar() is False
force_writer.reset(tok)
s.commit()Inline verification: the first assertion proves the reader is a standby; the second proves the forced read hit the primary.
Configuration Snippet
A minimal, production-shaped module tying it together:
# routing_complete.py
from contextvars import ContextVar
from sqlalchemy import create_engine, Update, Delete, Insert
from sqlalchemy.orm import Session, sessionmaker, scoped_session
force_writer: ContextVar[bool] = ContextVar("force_writer", default=False)
writer_engine = create_engine("postgresql+psycopg://app@primary.db.internal/app",
pool_size=10, max_overflow=20, pool_pre_ping=True)
reader_engine = create_engine("postgresql+psycopg://app_ro@replica-lb.internal/app",
pool_size=20, max_overflow=40, pool_pre_ping=True,
execution_options={"postgresql_readonly": True})
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, bind=None, **kw):
if bind is not None:
return bind
if force_writer.get() or kw.get("realm") == "writer":
return writer_engine
if self._flushing or isinstance(clause, (Update, Delete, Insert)):
return writer_engine
return reader_engine
Session = scoped_session(sessionmaker(class_=RoutingSession, expire_on_commit=False))Verification and Rollback
Confirm the routing is live and correct:
# Which backend served the last read?
Session.execute(text("SELECT inet_server_addr(), pg_is_in_recovery()")).one()
# Count checked-out connections per engine under load
writer_engine.pool.checkedout(), reader_engine.pool.checkedout()To roll back to single-engine behavior without touching call sites, make get_bind() return the writer unconditionally, or point reader_engine at the primary URL and redeploy:
class RoutingSession(Session):
def get_bind(self, mapper=None, clause=None, bind=None, **kw):
return writer_engine # temporary: everything on primaryThis is a safe kill switch during a replica incident: all traffic collapses onto the primary, and no application code changes. Once the replica is healthy again, restore the routing method and redeploy.
Edge Cases and Gotchas
Autoflush routes a read to the writer mid-transaction
If you add() an object and then run a select() in the same Session before committing, SQLAlchemy’s autoflush fires first. During that flush self._flushing is True, so the flush lands on the writer — correct. But the subsequent select() runs with _flushing back to False and goes to the reader, which has not seen the uncommitted write. Inside an uncommitted unit of work, prefer reading through the writer (set force_writer) or commit before reading. This is the SQLAlchemy-specific face of the general read-your-writes consistency problem.
One Session, two open transactions
A Session that both reads and writes opens a transaction on the reader and the writer. They are independent: the reader transaction sees a snapshot from before your uncommitted writes. Session.commit() commits both; Session.rollback() rolls back both. Keep sessions short and always close them (with SessionLocal() as s: or scoped_session.remove()), or you leak an idle-in-transaction connection on each engine.
Session.begin() and explicit transactions still split
Wrapping work in with Session.begin(): does not pin everything to the writer. get_bind() is still consulted per statement, so reads inside the block go to the replica unless you force the writer. If you need a fully serializable read-write transaction, run it entirely through the writer by setting force_writer for the block, or use a dedicated writer-only sessionmaker. For hard consistency requirements, see how to force primary reads for critical user transactions.
FAQ
Does a single SQLAlchemy Session open transactions on both engines at once?
Yes. A Session with multiple binds maintains an independent transaction per engine it touches. The first SELECT starts a read transaction on the reader; the first flush starts a write transaction on the writer. Session.commit() commits both, so a mid-request write followed by a read leaves two open transactions until commit or rollback.
Why do my reads go to the writer inside a transaction?
During a flush the Session sets self._flushing, and the recommended get_bind checks that flag. If you have already flushed pending writes in the same Session, autoflush will run before each query and route the flush to the writer. Use expire_on_commit=False with short sessions, or force explicit reads through the reader with bind_arguments if you accept the staleness.
How do I force a read onto the writer for read-your-writes?
Set a request-scoped ContextVar such as force_writer=True immediately after a write, and have get_bind return the writer engine while it is set. Alternatively pass bind_arguments={"realm": "writer"} to Session.execute, which forwards the key to get_bind. Clear the flag after the causal window closes so later reads return to the replica.
Related
← Back to ORM Middleware for Automatic Query Routing
- Configuring Django Database Routers for Read Replicas — the equivalent read/write split in Django using
DATABASE_ROUTERSinstead ofget_bind(). - ActiveRecord Role-Based Connection Switching for Replicas — how Rails handles the same split with
connected_toroles and a lag-aware delay window. - Read-Your-Writes Consistency with Read Replicas — the consistency model behind the
force_writerescape hatch. - Detecting and Handling Replication Lag in Real Time — measure the lag window that decides how long to keep
force_writeractive after a write.