Using a Single Session Factory for Multi-Schema Databases in SQLAlchemy


Handling multiple schemas in a FastAPI application often sounds more complex than it actually is.

A common misconception is that each schema requires its own SQLAlchemy engine or session factory. In reality, a single session factory is usually enough as long as each request gets its own session.

This post explains:

  • how multi-schema setups actually work in SQLAlchemy
  • two safe ways to target different schemas
  • and why sharing database sessions across requests is a subtle but serious bug

The core principle: sessions are request-scoped

Before talking about schemas, it’s important to get one thing right:

Database sessions are not reusable across requests.

In async applications especially, a session must be:

  • created per request
  • used by one logical flow
  • closed when that request ends

Violating this rule leads to:

  • race conditions
  • inconsistent reads
  • broken transactions
  • extremely hard-to-debug behavior

Once this principle is clear, multi-schema handling becomes much simpler.


One engine, one session factory, many schemas

SQLAlchemy sessions are not bound to a single schema.

They are bound to:

  • an engine
  • a database connection (from the pool)

Schemas are resolved at query time, not at engine creation time.

This means:

  • you don’t need multiple engines
  • you don’t need multiple session factories
  • you just need to control how schema resolution happens per request

Approach 1: Dynamic schema switching per request

This approach uses the database’s schema resolution mechanism. In PostgreSQL, that’s search_path.

Example

from sqlalchemy import text

async def get_db(schema: str):
    async with SessionLocal() as session:
        await session.execute(
            text(f"SET search_path TO {schema}")
        )
        yield session

Once search_path is set:

  • all ORM queries use that schema implicitly
  • models don’t need to specify a schema
  • the same code works across tenants

When this works well

  • multi-tenant systems
  • schema-per-customer designs
  • cases where schema changes per request

Things to be careful about

  • schema values must be validated (never trust user input blindly)
  • the session must not be reused
  • the schema switch must happen inside the session lifecycle

The schema is part of session state — not global state.


Approach 2: Explicit schema in the model

When schemas are static and known ahead of time, you can bind them directly in your models.

Example

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    __table_args__ = {"schema": "schema1"}

    id = Column(Integer, primary_key=True)
    name = Column(String)

Now:

  • the schema is explicit
  • no runtime switching is needed
  • queries are fully deterministic

When this works well

  • fixed schemas
  • reporting or internal tools
  • systems without per-request schema variation

The trade-off is flexibility: schema choice is now part of the model definition.


Why not share a session across requests?

This is where many subtle bugs come from.

Consider what happens if you reuse a session:

  • one request commits or rolls back
  • another request sees unexpected state
  • one request closes the session
  • another request suddenly fails
  • async tasks interleave unpredictably

In async environments, this can happen without obvious errors.

The code may work in development and fail only under load.

This is why session-per-request is not a style preference BUT it’s a correctness requirement.


The correct FastAPI pattern

FastAPI’s dependency system makes the safe pattern easy:

async def get_db():
    async with SessionLocal() as session:
        yield session

Each request:

  • gets a fresh session
  • controls its own transaction
  • cleans up automatically

Schema handling — whether dynamic or static — happens inside this boundary.


Putting it all together

You usually want:

  • one engine
  • one session factory
  • one session per request
  • schema resolution inside the session

Whether you choose dynamic switching or explicit schemas depends on your data model — not on SQLAlchemy limitations.


The takeaway

Handling multiple schemas in SQLAlchemy does not require multiple session factories.

What it does require is discipline:

  • fresh sessions per request
  • no shared session state
  • explicit schema resolution

Get those right, and multi-schema setups stay simple, predictable, and safe — even in async FastAPI applications.