[{"data":1,"prerenderedAt":4234},["ShallowReactive",2],{"page-\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing\u002F":3},{"id":4,"title":5,"body":6,"description":4226,"extension":4227,"meta":4228,"navigation":341,"path":4230,"seo":4231,"stem":4232,"__hash__":4233},"content\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing\u002Findex.md","Dynamic Schema and Multi-Tenant Routing in SQLAlchemy 2.0",{"type":7,"value":8,"toc":4202},"minimark",[9,13,23,243,248,260,283,711,718,825,832,836,842,920,930,1167,1173,1299,1303,1310,1520,1530,1548,1670,1679,1684,1694,1786,1789,1793,1797,1800,1874,1889,1893,1896,2147,2151,2163,2169,2288,2292,2318,2483,2492,2536,2540,2547,2755,2767,2778,2782,2789,2796,2830,2839,2978,2982,2991,3104,3117,3127,3131,3152,3346,3355,3359,3362,3918,3924,3928,4024,4028,4051,4080,4102,4136,4161,4165,4198],[10,11,5],"h1",{"id":12},"dynamic-schema-and-multi-tenant-routing-in-sqlalchemy-20",[14,15,16,17,22],"p",{},"SQLAlchemy 2.0 provides first-class primitives for directing every database operation to the correct schema or engine at runtime, without restructuring your ORM models. Within the broader ",[18,19,21],"a",{"href":20},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002F","Advanced Query Patterns and Bulk Data Operations"," discipline, dynamic schema routing sits at the intersection of connection pool management, transaction boundary design, and security isolation — making it one of the highest-stakes architectural decisions in a multi-tenant Python backend.",[24,25,28],"figure",{"className":26},[27],"diagram",[29,30,35,36,35,40,35,35,44,35,35,52,35,60,35,68,35,35,74,35,35,81,35,85,35,90,35,95,35,35,99,35,35,101,35,105,35,109,35,35,113,35,35,116,35,118,35,122,35,125,35,35,128,35,35,131,35,136,35,140,35,35,144,35,35,149,35,152,35,156,35,35,159,35,167,35,172,35,178,35,182,35,35,186,35,189,35,194,35,197,35,200,35,35,203,35,206,35,211,35,215,35,219,35,35,223],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 720 360","img","Request flow through tenant resolver to schema_translate_map and engine routing","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[37,38,39],"title",{},"Multi-Tenant Routing Architecture",[41,42,43],"desc",{},"An HTTP request passes through a tenant resolver, which either applies schema_translate_map on the connection for schema-per-tenant isolation, or selects a dedicated engine for database-per-tenant or read-replica routing.",[45,46],"rect",{"x":47,"y":47,"width":48,"height":49,"fill":50,"rx":51},"0","720","360","#f1f9f6","8",[45,53],{"x":54,"y":55,"width":56,"height":57,"rx":58,"fill":59},"20","140","130","52","6","#0f766e",[61,62,67],"text",{"x":63,"y":64,"fill":65,"style":66},"85","161","#ffffff","text-anchor:middle;font-size:13px;font-family:monospace","HTTP Request",[61,69,73],{"x":63,"y":70,"fill":71,"style":72},"181","#d1faf5","text-anchor:middle;font-size:12px","Tenant: acme",[75,76],"line",{"x1":77,"y1":78,"x2":79,"y2":78,"stroke":59,"style":80},"150","166","210","stroke-width:2;marker-end:url(#arr)",[45,82],{"x":79,"y":56,"width":77,"height":83,"rx":58,"fill":84},"72","#123a35",[61,86,89],{"x":87,"y":88,"fill":65,"style":66},"285","155","Tenant Resolver",[61,91,94],{"x":87,"y":92,"fill":71,"style":93},"175","text-anchor:middle;font-size:11px;font-family:monospace","lookup(tenant_id)",[61,96,98],{"x":87,"y":97,"fill":71,"style":93},"192","→ schema \u002F engine",[75,100],{"x1":87,"y1":56,"x2":87,"y2":83,"stroke":59,"style":80},[45,102],{"x":103,"y":54,"width":103,"height":57,"rx":58,"fill":104},"190","#1f9f95",[61,106,108],{"x":87,"y":107,"fill":65,"style":66},"42","schema_translate_map",[61,110,112],{"x":87,"y":111,"fill":50,"style":93},"60","{None: \"acme_schema\"}",[75,114],{"x1":49,"y1":78,"x2":115,"y2":78,"stroke":59,"style":80},"420",[45,117],{"x":115,"y":56,"width":88,"height":83,"rx":58,"fill":84},[61,119,121],{"x":120,"y":88,"fill":65,"style":66},"497","Engine Router",[61,123,124],{"x":120,"y":92,"fill":71,"style":93},"write → primary",[61,126,127],{"x":120,"y":97,"fill":71,"style":93},"read → replica",[75,129],{"x1":130,"y1":56,"x2":115,"y2":83,"stroke":59,"style":80},"460",[45,132],{"x":133,"y":54,"width":134,"height":135,"rx":58,"fill":59},"370","110","48",[61,137,139],{"x":138,"y":107,"fill":65,"style":66},"425","Primary DB",[61,141,143],{"x":138,"y":142,"fill":71,"style":93},"59","writes \u002F DDL",[75,145],{"x1":146,"y1":56,"x2":147,"y2":83,"stroke":104,"style":148},"535","560","stroke-width:2;stroke-dasharray:5,3;marker-end:url(#arr2)",[45,150],{"x":151,"y":54,"width":134,"height":135,"rx":58,"fill":104},"510",[61,153,155],{"x":154,"y":107,"fill":65,"style":66},"565","Read Replica",[61,157,158],{"x":154,"y":142,"fill":50,"style":93},"SELECT queries",[45,160],{"x":161,"y":162,"width":163,"height":164,"rx":58,"fill":65,"stroke":165,"style":166},"30","250","220","80","rgba(15,118,110,0.28)","stroke-width:1.5",[61,168,171],{"x":55,"y":169,"fill":59,"style":170},"272","text-anchor:middle;font-size:12px;font-weight:bold","search_path",[61,173,177],{"x":55,"y":174,"fill":175,"style":176},"291","#3f4f4b","text-anchor:middle;font-size:11px","SET path = acme;",[61,179,181],{"x":55,"y":180,"fill":175,"style":176},"308","names resolve to",[61,183,185],{"x":55,"y":184,"fill":175,"style":176},"325","tenant schema",[45,187],{"x":188,"y":162,"width":163,"height":164,"rx":58,"fill":65,"stroke":165,"style":166},"290",[61,190,193],{"x":191,"y":169,"fill":59,"style":192},"400","text-anchor:middle;font-size:12px;font-weight:bold;font-family:monospace","Vertical Partitioning",[61,195,196],{"x":191,"y":174,"fill":175,"style":93},"domain A → engine_a",[61,198,199],{"x":191,"y":180,"fill":175,"style":93},"domain B → engine_b",[61,201,202],{"x":191,"y":184,"fill":175,"style":93},"via get_bind() routing",[45,204],{"x":205,"y":162,"width":77,"height":164,"rx":58,"fill":65,"stroke":165,"style":166},"550",[61,207,210],{"x":208,"y":169,"fill":59,"style":209},"616","text-anchor:middle;font-size:11px;font-weight:bold","DB-per-Tenant",[61,212,214],{"x":208,"y":213,"fill":175,"style":176},"289","engine\u002Ftenant",[61,216,218],{"x":208,"y":217,"fill":175,"style":176},"306","full isolation",[61,220,222],{"x":208,"y":221,"fill":175,"style":176},"323","more pool cost",[224,225,226,227,226,238,35],"defs",{},"\n    ",[228,229,233,234,226],"marker",{"id":230,"markerWidth":51,"markerHeight":51,"refX":58,"refY":231,"orient":232},"arr","3","auto","\n      ",[235,236],"path",{"d":237,"fill":59},"M0,0 L0,6 L8,3 z",[228,239,233,241,226],{"id":240,"markerWidth":51,"markerHeight":51,"refX":58,"refY":231,"orient":232},"arr2",[235,242],{"d":237,"fill":104},[244,245,247],"h2",{"id":246},"concept-execution-model","Concept & Execution Model",[14,249,250,251,255,256,259],{},"SQLAlchemy 2.0's unified execution model distinguishes sharply between ",[252,253,254],"em",{},"where"," a query runs (which engine\u002Fconnection) and ",[252,257,258],{},"which schema"," it targets (which set of tables the SQL names resolve against). Multi-tenant systems exploit both dimensions independently or together.",[14,261,262,265,266,270,271,274,275,278,279,282],{},[263,264,108],"strong",{}," is the central primitive for schema-per-tenant isolation. When applied via ",[267,268,269],"code",{},"execution_options(schema_translate_map={None: \"acme\"})",", SQLAlchemy rewrites every unqualified table reference in the compiled SQL — ",[267,272,273],{},"INSERT INTO users"," becomes ",[267,276,277],{},"INSERT INTO acme.users"," — without altering model definitions. The map key is the schema name as declared in the model (",[267,280,281],{},"__table_args__ = {\"schema\": None}"," meaning \"default schema\") and the value is the runtime target schema.",[284,285,290],"pre",{"className":286,"code":287,"language":288,"meta":289,"style":289},"language-python shiki shiki-themes github-light github-dark","from sqlalchemy import select, text\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker\nfrom sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column\n\nengine = create_async_engine(\n    \"postgresql+asyncpg:\u002F\u002Fuser:pass@localhost\u002Fsaas_db\",\n    pool_size=20,\n    max_overflow=10,\n    pool_pre_ping=True,\n)\n\nasync_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\n\nclass Base(DeclarativeBase):\n    pass\n\n\nclass Tenant(Base):\n    __tablename__ = \"tenants\"\n    id: Mapped[int] = mapped_column(primary_key=True)\n    slug: Mapped[str] = mapped_column(unique=True)\n    schema_name: Mapped[str]\n\n\nclass Invoice(Base):\n    # schema=None means \"default\" — schema_translate_map will replace None at runtime\n    __tablename__ = \"invoices\"\n    __table_args__ = {\"schema\": None}\n    id: Mapped[int] = mapped_column(primary_key=True)\n    tenant_id: Mapped[int]\n    amount_cents: Mapped[int]\n    paid: Mapped[bool] = mapped_column(default=False)\n","python","",[267,291,292,310,323,336,343,355,365,379,392,405,411,416,445,450,455,474,480,485,490,505,516,545,569,580,585,590,604,611,621,644,667,677,687],{"__ignoreMap":289},[293,294,296,300,304,307],"span",{"class":75,"line":295},1,[293,297,299],{"class":298},"szBVR","from",[293,301,303],{"class":302},"sVt8B"," sqlalchemy ",[293,305,306],{"class":298},"import",[293,308,309],{"class":302}," select, text\n",[293,311,313,315,318,320],{"class":75,"line":312},2,[293,314,299],{"class":298},[293,316,317],{"class":302}," sqlalchemy.ext.asyncio ",[293,319,306],{"class":298},[293,321,322],{"class":302}," AsyncSession, create_async_engine, async_sessionmaker\n",[293,324,326,328,331,333],{"class":75,"line":325},3,[293,327,299],{"class":298},[293,329,330],{"class":302}," sqlalchemy.orm ",[293,332,306],{"class":298},[293,334,335],{"class":302}," DeclarativeBase, Mapped, mapped_column\n",[293,337,339],{"class":75,"line":338},4,[293,340,342],{"emptyLinePlaceholder":341},true,"\n",[293,344,346,349,352],{"class":75,"line":345},5,[293,347,348],{"class":302},"engine ",[293,350,351],{"class":298},"=",[293,353,354],{"class":302}," create_async_engine(\n",[293,356,358,362],{"class":75,"line":357},6,[293,359,361],{"class":360},"sZZnC","    \"postgresql+asyncpg:\u002F\u002Fuser:pass@localhost\u002Fsaas_db\"",[293,363,364],{"class":302},",\n",[293,366,368,372,374,377],{"class":75,"line":367},7,[293,369,371],{"class":370},"s4XuR","    pool_size",[293,373,351],{"class":298},[293,375,54],{"class":376},"sj4cs",[293,378,364],{"class":302},[293,380,382,385,387,390],{"class":75,"line":381},8,[293,383,384],{"class":370},"    max_overflow",[293,386,351],{"class":298},[293,388,389],{"class":376},"10",[293,391,364],{"class":302},[293,393,395,398,400,403],{"class":75,"line":394},9,[293,396,397],{"class":370},"    pool_pre_ping",[293,399,351],{"class":298},[293,401,402],{"class":376},"True",[293,404,364],{"class":302},[293,406,408],{"class":75,"line":407},10,[293,409,410],{"class":302},")\n",[293,412,414],{"class":75,"line":413},11,[293,415,342],{"emptyLinePlaceholder":341},[293,417,419,422,424,427,430,432,435,438,440,443],{"class":75,"line":418},12,[293,420,421],{"class":302},"async_session ",[293,423,351],{"class":298},[293,425,426],{"class":302}," async_sessionmaker(engine, ",[293,428,429],{"class":370},"class_",[293,431,351],{"class":298},[293,433,434],{"class":302},"AsyncSession, ",[293,436,437],{"class":370},"expire_on_commit",[293,439,351],{"class":298},[293,441,442],{"class":376},"False",[293,444,410],{"class":302},[293,446,448],{"class":75,"line":447},13,[293,449,342],{"emptyLinePlaceholder":341},[293,451,453],{"class":75,"line":452},14,[293,454,342],{"emptyLinePlaceholder":341},[293,456,458,461,465,468,471],{"class":75,"line":457},15,[293,459,460],{"class":298},"class",[293,462,464],{"class":463},"sScJk"," Base",[293,466,467],{"class":302},"(",[293,469,470],{"class":463},"DeclarativeBase",[293,472,473],{"class":302},"):\n",[293,475,477],{"class":75,"line":476},16,[293,478,479],{"class":298},"    pass\n",[293,481,483],{"class":75,"line":482},17,[293,484,342],{"emptyLinePlaceholder":341},[293,486,488],{"class":75,"line":487},18,[293,489,342],{"emptyLinePlaceholder":341},[293,491,493,495,498,500,503],{"class":75,"line":492},19,[293,494,460],{"class":298},[293,496,497],{"class":463}," Tenant",[293,499,467],{"class":302},[293,501,502],{"class":463},"Base",[293,504,473],{"class":302},[293,506,508,511,513],{"class":75,"line":507},20,[293,509,510],{"class":302},"    __tablename__ ",[293,512,351],{"class":298},[293,514,515],{"class":360}," \"tenants\"\n",[293,517,519,522,525,528,531,533,536,539,541,543],{"class":75,"line":518},21,[293,520,521],{"class":376},"    id",[293,523,524],{"class":302},": Mapped[",[293,526,527],{"class":376},"int",[293,529,530],{"class":302},"] ",[293,532,351],{"class":298},[293,534,535],{"class":302}," mapped_column(",[293,537,538],{"class":370},"primary_key",[293,540,351],{"class":298},[293,542,402],{"class":376},[293,544,410],{"class":302},[293,546,548,551,554,556,558,560,563,565,567],{"class":75,"line":547},22,[293,549,550],{"class":302},"    slug: Mapped[",[293,552,553],{"class":376},"str",[293,555,530],{"class":302},[293,557,351],{"class":298},[293,559,535],{"class":302},[293,561,562],{"class":370},"unique",[293,564,351],{"class":298},[293,566,402],{"class":376},[293,568,410],{"class":302},[293,570,572,575,577],{"class":75,"line":571},23,[293,573,574],{"class":302},"    schema_name: Mapped[",[293,576,553],{"class":376},[293,578,579],{"class":302},"]\n",[293,581,583],{"class":75,"line":582},24,[293,584,342],{"emptyLinePlaceholder":341},[293,586,588],{"class":75,"line":587},25,[293,589,342],{"emptyLinePlaceholder":341},[293,591,593,595,598,600,602],{"class":75,"line":592},26,[293,594,460],{"class":298},[293,596,597],{"class":463}," Invoice",[293,599,467],{"class":302},[293,601,502],{"class":463},[293,603,473],{"class":302},[293,605,607],{"class":75,"line":606},27,[293,608,610],{"class":609},"sJ8bj","    # schema=None means \"default\" — schema_translate_map will replace None at runtime\n",[293,612,614,616,618],{"class":75,"line":613},28,[293,615,510],{"class":302},[293,617,351],{"class":298},[293,619,620],{"class":360}," \"invoices\"\n",[293,622,624,627,629,632,635,638,641],{"class":75,"line":623},29,[293,625,626],{"class":302},"    __table_args__ ",[293,628,351],{"class":298},[293,630,631],{"class":302}," {",[293,633,634],{"class":360},"\"schema\"",[293,636,637],{"class":302},": ",[293,639,640],{"class":376},"None",[293,642,643],{"class":302},"}\n",[293,645,647,649,651,653,655,657,659,661,663,665],{"class":75,"line":646},30,[293,648,521],{"class":376},[293,650,524],{"class":302},[293,652,527],{"class":376},[293,654,530],{"class":302},[293,656,351],{"class":298},[293,658,535],{"class":302},[293,660,538],{"class":370},[293,662,351],{"class":298},[293,664,402],{"class":376},[293,666,410],{"class":302},[293,668,670,673,675],{"class":75,"line":669},31,[293,671,672],{"class":302},"    tenant_id: Mapped[",[293,674,527],{"class":376},[293,676,579],{"class":302},[293,678,680,683,685],{"class":75,"line":679},32,[293,681,682],{"class":302},"    amount_cents: Mapped[",[293,684,527],{"class":376},[293,686,579],{"class":302},[293,688,690,693,696,698,700,702,705,707,709],{"class":75,"line":689},33,[293,691,692],{"class":302},"    paid: Mapped[",[293,694,695],{"class":376},"bool",[293,697,530],{"class":302},[293,699,351],{"class":298},[293,701,535],{"class":302},[293,703,704],{"class":370},"default",[293,706,351],{"class":298},[293,708,442],{"class":376},[293,710,410],{"class":302},[14,712,713,714,717],{},"At query time, the session or connection receives ",[267,715,716],{},"execution_options"," that carry the per-request schema mapping:",[284,719,721],{"className":286,"code":720,"language":288,"meta":289,"style":289},"async def fetch_invoices_for_tenant(\n    session: AsyncSession, schema_name: str\n) -> list[Invoice]:\n    stmt = select(Invoice).where(Invoice.paid == False)\n    # schema_translate_map rewrites {None} → schema_name in the emitted SQL\n    result = await session.execute(\n        stmt,\n        execution_options={\"schema_translate_map\": {None: schema_name}},\n    )\n    return result.scalars().all()\n",[267,722,723,737,745,750,768,773,786,791,812,817],{"__ignoreMap":289},[293,724,725,728,731,734],{"class":75,"line":295},[293,726,727],{"class":298},"async",[293,729,730],{"class":298}," def",[293,732,733],{"class":463}," fetch_invoices_for_tenant",[293,735,736],{"class":302},"(\n",[293,738,739,742],{"class":75,"line":312},[293,740,741],{"class":302},"    session: AsyncSession, schema_name: ",[293,743,744],{"class":376},"str\n",[293,746,747],{"class":75,"line":325},[293,748,749],{"class":302},") -> list[Invoice]:\n",[293,751,752,755,757,760,763,766],{"class":75,"line":338},[293,753,754],{"class":302},"    stmt ",[293,756,351],{"class":298},[293,758,759],{"class":302}," select(Invoice).where(Invoice.paid ",[293,761,762],{"class":298},"==",[293,764,765],{"class":376}," False",[293,767,410],{"class":302},[293,769,770],{"class":75,"line":345},[293,771,772],{"class":609},"    # schema_translate_map rewrites {None} → schema_name in the emitted SQL\n",[293,774,775,778,780,783],{"class":75,"line":357},[293,776,777],{"class":302},"    result ",[293,779,351],{"class":298},[293,781,782],{"class":298}," await",[293,784,785],{"class":302}," session.execute(\n",[293,787,788],{"class":75,"line":367},[293,789,790],{"class":302},"        stmt,\n",[293,792,793,796,798,801,804,807,809],{"class":75,"line":381},[293,794,795],{"class":370},"        execution_options",[293,797,351],{"class":298},[293,799,800],{"class":302},"{",[293,802,803],{"class":360},"\"schema_translate_map\"",[293,805,806],{"class":302},": {",[293,808,640],{"class":376},[293,810,811],{"class":302},": schema_name}},\n",[293,813,814],{"class":75,"line":394},[293,815,816],{"class":302},"    )\n",[293,818,819,822],{"class":75,"line":407},[293,820,821],{"class":298},"    return",[293,823,824],{"class":302}," result.scalars().all()\n",[14,826,827,828,831],{},"This compiles to ",[267,829,830],{},"SELECT invoices.id, ... FROM acme.invoices WHERE invoices.paid = false"," — the ORM model itself never changes.",[244,833,835],{"id":834},"query-construction-async-execution-patterns","Query Construction & Async Execution Patterns",[14,837,838,839,841],{},"There are three levels at which you can apply ",[267,840,108],{},", each with different scope and lifetime:",[843,844,845,864],"table",{},[846,847,848],"thead",{},[849,850,851,855,858,861],"tr",{},[852,853,854],"th",{},"Level",[852,856,857],{},"API",[852,859,860],{},"Lifetime",[852,862,863],{},"Use case",[865,866,867,884,900],"tbody",{},[849,868,869,873,878,881],{},[870,871,872],"td",{},"Engine",[870,874,875],{},[267,876,877],{},"engine.execution_options(schema_translate_map=...)",[870,879,880],{},"All connections from this engine",[870,882,883],{},"Single-tenant applications",[849,885,886,889,894,897],{},[870,887,888],{},"Connection",[870,890,891],{},[267,892,893],{},"conn.execution_options(schema_translate_map=...)",[870,895,896],{},"Single connection checkout",[870,898,899],{},"Fine-grained per-connection control",[849,901,902,905,914,917],{},[870,903,904],{},"Session (via execute)",[870,906,907,908,910,911],{},"Pass as ",[267,909,716],{}," kwarg to ",[267,912,913],{},"session.execute()",[870,915,916],{},"Single statement",[870,918,919],{},"Per-request routing (recommended for async)",[14,921,922,923,925,926,929],{},"For async production code, applying the map at the ",[267,924,913],{}," call is the cleanest pattern because it does not mutate shared state — multiple concurrent coroutines can hold the same ",[267,927,928],{},"AsyncSession"," factory while routing to different schemas without interference.",[284,931,933],{"className":286,"code":932,"language":288,"meta":289,"style":289},"# Async: per-statement schema routing (safest under concurrent load)\nasync def get_tenant_orders(\n    session: AsyncSession,\n    tenant_schema: str,\n    user_id: int,\n) -> list[dict]:\n    from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship\n\n    class Order(Base):\n        __tablename__ = \"orders\"\n        __table_args__ = {\"schema\": None}\n        id: Mapped[int] = mapped_column(primary_key=True)\n        user_id: Mapped[int]\n        total_cents: Mapped[int]\n\n    stmt = select(Order).where(Order.user_id == user_id)\n    result = await session.execute(\n        stmt,\n        execution_options={\"schema_translate_map\": {None: tenant_schema}},\n    )\n    return [{\"id\": r.id, \"total\": r.total_cents} for r in result.scalars()]\n",[267,934,935,940,951,956,965,974,985,997,1001,1015,1025,1042,1065,1074,1083,1087,1101,1111,1115,1132,1136],{"__ignoreMap":289},[293,936,937],{"class":75,"line":295},[293,938,939],{"class":609},"# Async: per-statement schema routing (safest under concurrent load)\n",[293,941,942,944,946,949],{"class":75,"line":312},[293,943,727],{"class":298},[293,945,730],{"class":298},[293,947,948],{"class":463}," get_tenant_orders",[293,950,736],{"class":302},[293,952,953],{"class":75,"line":325},[293,954,955],{"class":302},"    session: AsyncSession,\n",[293,957,958,961,963],{"class":75,"line":338},[293,959,960],{"class":302},"    tenant_schema: ",[293,962,553],{"class":376},[293,964,364],{"class":302},[293,966,967,970,972],{"class":75,"line":345},[293,968,969],{"class":302},"    user_id: ",[293,971,527],{"class":376},[293,973,364],{"class":302},[293,975,976,979,982],{"class":75,"line":357},[293,977,978],{"class":302},") -> list[",[293,980,981],{"class":376},"dict",[293,983,984],{"class":302},"]:\n",[293,986,987,990,992,994],{"class":75,"line":367},[293,988,989],{"class":298},"    from",[293,991,330],{"class":302},[293,993,306],{"class":298},[293,995,996],{"class":302}," DeclarativeBase, Mapped, mapped_column, relationship\n",[293,998,999],{"class":75,"line":381},[293,1000,342],{"emptyLinePlaceholder":341},[293,1002,1003,1006,1009,1011,1013],{"class":75,"line":394},[293,1004,1005],{"class":298},"    class",[293,1007,1008],{"class":463}," Order",[293,1010,467],{"class":302},[293,1012,502],{"class":463},[293,1014,473],{"class":302},[293,1016,1017,1020,1022],{"class":75,"line":407},[293,1018,1019],{"class":302},"        __tablename__ ",[293,1021,351],{"class":298},[293,1023,1024],{"class":360}," \"orders\"\n",[293,1026,1027,1030,1032,1034,1036,1038,1040],{"class":75,"line":413},[293,1028,1029],{"class":302},"        __table_args__ ",[293,1031,351],{"class":298},[293,1033,631],{"class":302},[293,1035,634],{"class":360},[293,1037,637],{"class":302},[293,1039,640],{"class":376},[293,1041,643],{"class":302},[293,1043,1044,1047,1049,1051,1053,1055,1057,1059,1061,1063],{"class":75,"line":418},[293,1045,1046],{"class":376},"        id",[293,1048,524],{"class":302},[293,1050,527],{"class":376},[293,1052,530],{"class":302},[293,1054,351],{"class":298},[293,1056,535],{"class":302},[293,1058,538],{"class":370},[293,1060,351],{"class":298},[293,1062,402],{"class":376},[293,1064,410],{"class":302},[293,1066,1067,1070,1072],{"class":75,"line":447},[293,1068,1069],{"class":302},"        user_id: Mapped[",[293,1071,527],{"class":376},[293,1073,579],{"class":302},[293,1075,1076,1079,1081],{"class":75,"line":452},[293,1077,1078],{"class":302},"        total_cents: Mapped[",[293,1080,527],{"class":376},[293,1082,579],{"class":302},[293,1084,1085],{"class":75,"line":457},[293,1086,342],{"emptyLinePlaceholder":341},[293,1088,1089,1091,1093,1096,1098],{"class":75,"line":476},[293,1090,754],{"class":302},[293,1092,351],{"class":298},[293,1094,1095],{"class":302}," select(Order).where(Order.user_id ",[293,1097,762],{"class":298},[293,1099,1100],{"class":302}," user_id)\n",[293,1102,1103,1105,1107,1109],{"class":75,"line":482},[293,1104,777],{"class":302},[293,1106,351],{"class":298},[293,1108,782],{"class":298},[293,1110,785],{"class":302},[293,1112,1113],{"class":75,"line":487},[293,1114,790],{"class":302},[293,1116,1117,1119,1121,1123,1125,1127,1129],{"class":75,"line":492},[293,1118,795],{"class":370},[293,1120,351],{"class":298},[293,1122,800],{"class":302},[293,1124,803],{"class":360},[293,1126,806],{"class":302},[293,1128,640],{"class":376},[293,1130,1131],{"class":302},": tenant_schema}},\n",[293,1133,1134],{"class":75,"line":507},[293,1135,816],{"class":302},[293,1137,1138,1140,1143,1146,1149,1152,1155,1158,1161,1164],{"class":75,"line":518},[293,1139,821],{"class":298},[293,1141,1142],{"class":302}," [{",[293,1144,1145],{"class":360},"\"id\"",[293,1147,1148],{"class":302},": r.id, ",[293,1150,1151],{"class":360},"\"total\"",[293,1153,1154],{"class":302},": r.total_cents} ",[293,1156,1157],{"class":298},"for",[293,1159,1160],{"class":302}," r ",[293,1162,1163],{"class":298},"in",[293,1165,1166],{"class":302}," result.scalars()]\n",[14,1168,1169,1172],{},[263,1170,1171],{},"Sync variant"," for background workers or Alembic migration scripts:",[284,1174,1176],{"className":286,"code":1175,"language":288,"meta":289,"style":289},"# Sync: applying schema_translate_map on a connection directly\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import Session\n\nsync_engine = create_engine(\"postgresql+psycopg2:\u002F\u002Fuser:pass@localhost\u002Fsaas_db\")\n\nwith sync_engine.connect() as conn:\n    schema_conn = conn.execution_options(schema_translate_map={None: \"acme\"})\n    with Session(bind=schema_conn) as session:\n        invoices = session.execute(select(Invoice)).scalars().all()\n",[267,1177,1178,1183,1194,1205,1209,1224,1228,1242,1268,1289],{"__ignoreMap":289},[293,1179,1180],{"class":75,"line":295},[293,1181,1182],{"class":609},"# Sync: applying schema_translate_map on a connection directly\n",[293,1184,1185,1187,1189,1191],{"class":75,"line":312},[293,1186,299],{"class":298},[293,1188,303],{"class":302},[293,1190,306],{"class":298},[293,1192,1193],{"class":302}," create_engine\n",[293,1195,1196,1198,1200,1202],{"class":75,"line":325},[293,1197,299],{"class":298},[293,1199,330],{"class":302},[293,1201,306],{"class":298},[293,1203,1204],{"class":302}," Session\n",[293,1206,1207],{"class":75,"line":338},[293,1208,342],{"emptyLinePlaceholder":341},[293,1210,1211,1214,1216,1219,1222],{"class":75,"line":345},[293,1212,1213],{"class":302},"sync_engine ",[293,1215,351],{"class":298},[293,1217,1218],{"class":302}," create_engine(",[293,1220,1221],{"class":360},"\"postgresql+psycopg2:\u002F\u002Fuser:pass@localhost\u002Fsaas_db\"",[293,1223,410],{"class":302},[293,1225,1226],{"class":75,"line":357},[293,1227,342],{"emptyLinePlaceholder":341},[293,1229,1230,1233,1236,1239],{"class":75,"line":367},[293,1231,1232],{"class":298},"with",[293,1234,1235],{"class":302}," sync_engine.connect() ",[293,1237,1238],{"class":298},"as",[293,1240,1241],{"class":302}," conn:\n",[293,1243,1244,1247,1249,1252,1254,1256,1258,1260,1262,1265],{"class":75,"line":381},[293,1245,1246],{"class":302},"    schema_conn ",[293,1248,351],{"class":298},[293,1250,1251],{"class":302}," conn.execution_options(",[293,1253,108],{"class":370},[293,1255,351],{"class":298},[293,1257,800],{"class":302},[293,1259,640],{"class":376},[293,1261,637],{"class":302},[293,1263,1264],{"class":360},"\"acme\"",[293,1266,1267],{"class":302},"})\n",[293,1269,1270,1273,1276,1279,1281,1284,1286],{"class":75,"line":394},[293,1271,1272],{"class":298},"    with",[293,1274,1275],{"class":302}," Session(",[293,1277,1278],{"class":370},"bind",[293,1280,351],{"class":298},[293,1282,1283],{"class":302},"schema_conn) ",[293,1285,1238],{"class":298},[293,1287,1288],{"class":302}," session:\n",[293,1290,1291,1294,1296],{"class":75,"line":407},[293,1292,1293],{"class":302},"        invoices ",[293,1295,351],{"class":298},[293,1297,1298],{"class":302}," session.execute(select(Invoice)).scalars().all()\n",[244,1300,1302],{"id":1301},"state-management-session-boundaries","State Management & Session Boundaries",[14,1304,1305,1306,1309],{},"Schema routing intersects with SQLAlchemy's session identity map in a subtle way: if two coroutines share a session and execute against different schemas, the identity map will cache the same primary key from two different schemas under the same ORM class, producing silent data corruption. The safe pattern is ",[263,1307,1308],{},"one session per request, session factory shared globally",":",[284,1311,1313],{"className":286,"code":1312,"language":288,"meta":289,"style":289},"from contextlib import asynccontextmanager\nfrom fastapi import Depends, Request\n\n\ndef get_tenant_schema(request: Request) -> str:\n    # In production, derive from JWT claim or verified subdomain\n    tenant_slug = request.headers.get(\"X-Tenant-ID\", \"public\")\n    # Validate against an allowlist — never pass raw header values to SQL\n    allowed = {\"acme\": \"acme\", \"globex\": \"globex\", \"initech\": \"initech\"}\n    return allowed[tenant_slug]  # KeyError becomes a 422\u002F403 upstream\n\n\n@asynccontextmanager\nasync def tenant_session(schema: str):\n    async with async_session() as session:\n        async with session.begin():\n            # Bind schema to session for the request lifetime\n            session.info[\"schema\"] = schema\n            yield session\n",[267,1314,1315,1327,1339,1343,1347,1363,1368,1389,1394,1429,1439,1443,1447,1452,1468,1483,1493,1498,1512],{"__ignoreMap":289},[293,1316,1317,1319,1322,1324],{"class":75,"line":295},[293,1318,299],{"class":298},[293,1320,1321],{"class":302}," contextlib ",[293,1323,306],{"class":298},[293,1325,1326],{"class":302}," asynccontextmanager\n",[293,1328,1329,1331,1334,1336],{"class":75,"line":312},[293,1330,299],{"class":298},[293,1332,1333],{"class":302}," fastapi ",[293,1335,306],{"class":298},[293,1337,1338],{"class":302}," Depends, Request\n",[293,1340,1341],{"class":75,"line":325},[293,1342,342],{"emptyLinePlaceholder":341},[293,1344,1345],{"class":75,"line":338},[293,1346,342],{"emptyLinePlaceholder":341},[293,1348,1349,1352,1355,1358,1360],{"class":75,"line":345},[293,1350,1351],{"class":298},"def",[293,1353,1354],{"class":463}," get_tenant_schema",[293,1356,1357],{"class":302},"(request: Request) -> ",[293,1359,553],{"class":376},[293,1361,1362],{"class":302},":\n",[293,1364,1365],{"class":75,"line":357},[293,1366,1367],{"class":609},"    # In production, derive from JWT claim or verified subdomain\n",[293,1369,1370,1373,1375,1378,1381,1384,1387],{"class":75,"line":367},[293,1371,1372],{"class":302},"    tenant_slug ",[293,1374,351],{"class":298},[293,1376,1377],{"class":302}," request.headers.get(",[293,1379,1380],{"class":360},"\"X-Tenant-ID\"",[293,1382,1383],{"class":302},", ",[293,1385,1386],{"class":360},"\"public\"",[293,1388,410],{"class":302},[293,1390,1391],{"class":75,"line":381},[293,1392,1393],{"class":609},"    # Validate against an allowlist — never pass raw header values to SQL\n",[293,1395,1396,1399,1401,1403,1405,1407,1409,1411,1414,1416,1418,1420,1423,1425,1427],{"class":75,"line":394},[293,1397,1398],{"class":302},"    allowed ",[293,1400,351],{"class":298},[293,1402,631],{"class":302},[293,1404,1264],{"class":360},[293,1406,637],{"class":302},[293,1408,1264],{"class":360},[293,1410,1383],{"class":302},[293,1412,1413],{"class":360},"\"globex\"",[293,1415,637],{"class":302},[293,1417,1413],{"class":360},[293,1419,1383],{"class":302},[293,1421,1422],{"class":360},"\"initech\"",[293,1424,637],{"class":302},[293,1426,1422],{"class":360},[293,1428,643],{"class":302},[293,1430,1431,1433,1436],{"class":75,"line":407},[293,1432,821],{"class":298},[293,1434,1435],{"class":302}," allowed[tenant_slug]  ",[293,1437,1438],{"class":609},"# KeyError becomes a 422\u002F403 upstream\n",[293,1440,1441],{"class":75,"line":413},[293,1442,342],{"emptyLinePlaceholder":341},[293,1444,1445],{"class":75,"line":418},[293,1446,342],{"emptyLinePlaceholder":341},[293,1448,1449],{"class":75,"line":447},[293,1450,1451],{"class":463},"@asynccontextmanager\n",[293,1453,1454,1456,1458,1461,1464,1466],{"class":75,"line":452},[293,1455,727],{"class":298},[293,1457,730],{"class":298},[293,1459,1460],{"class":463}," tenant_session",[293,1462,1463],{"class":302},"(schema: ",[293,1465,553],{"class":376},[293,1467,473],{"class":302},[293,1469,1470,1473,1476,1479,1481],{"class":75,"line":457},[293,1471,1472],{"class":298},"    async",[293,1474,1475],{"class":298}," with",[293,1477,1478],{"class":302}," async_session() ",[293,1480,1238],{"class":298},[293,1482,1288],{"class":302},[293,1484,1485,1488,1490],{"class":75,"line":476},[293,1486,1487],{"class":298},"        async",[293,1489,1475],{"class":298},[293,1491,1492],{"class":302}," session.begin():\n",[293,1494,1495],{"class":75,"line":482},[293,1496,1497],{"class":609},"            # Bind schema to session for the request lifetime\n",[293,1499,1500,1503,1505,1507,1509],{"class":75,"line":487},[293,1501,1502],{"class":302},"            session.info[",[293,1504,634],{"class":360},[293,1506,530],{"class":302},[293,1508,351],{"class":298},[293,1510,1511],{"class":302}," schema\n",[293,1513,1514,1517],{"class":75,"line":492},[293,1515,1516],{"class":298},"            yield",[293,1518,1519],{"class":302}," session\n",[14,1521,1522,1523,1526,1527,1529],{},"When using this approach, helper functions read ",[267,1524,1525],{},"session.info[\"schema\"]"," and pass it via ",[267,1528,716],{},", keeping schema derivation centralized and auditable.",[14,1531,1532,1537,1538,1540,1541,1544,1545,1547],{},[263,1533,1534,1535],{},"PostgreSQL ",[267,1536,171],{}," is an alternative to ",[267,1539,108],{}," for environments where you control the connection-level session variable. Setting ",[267,1542,1543],{},"SET search_path = acme"," via a connection event ensures all unqualified names resolve to the tenant schema without any SQLAlchemy rewriting. The tradeoff: ",[267,1546,171],{}," is connection-scoped, which means PgBouncer in transaction pooling mode can leak it to the next session unless you reset it on checkout.",[284,1549,1551],{"className":286,"code":1550,"language":288,"meta":289,"style":289},"from sqlalchemy import event\nfrom sqlalchemy.ext.asyncio import AsyncEngine\n\n\ndef configure_search_path(engine: AsyncEngine, schema: str) -> None:\n    @event.listens_for(engine.sync_engine, \"connect\")\n    def set_search_path(dbapi_conn, connection_record):\n        cursor = dbapi_conn.cursor()\n        # Parameterized SET is not supported — validate schema name before use\n        cursor.execute(f\"SET search_path = {schema}, public\")\n        cursor.close()\n",[267,1552,1553,1564,1575,1579,1583,1602,1615,1626,1636,1641,1665],{"__ignoreMap":289},[293,1554,1555,1557,1559,1561],{"class":75,"line":295},[293,1556,299],{"class":298},[293,1558,303],{"class":302},[293,1560,306],{"class":298},[293,1562,1563],{"class":302}," event\n",[293,1565,1566,1568,1570,1572],{"class":75,"line":312},[293,1567,299],{"class":298},[293,1569,317],{"class":302},[293,1571,306],{"class":298},[293,1573,1574],{"class":302}," AsyncEngine\n",[293,1576,1577],{"class":75,"line":325},[293,1578,342],{"emptyLinePlaceholder":341},[293,1580,1581],{"class":75,"line":338},[293,1582,342],{"emptyLinePlaceholder":341},[293,1584,1585,1587,1590,1593,1595,1598,1600],{"class":75,"line":345},[293,1586,1351],{"class":298},[293,1588,1589],{"class":463}," configure_search_path",[293,1591,1592],{"class":302},"(engine: AsyncEngine, schema: ",[293,1594,553],{"class":376},[293,1596,1597],{"class":302},") -> ",[293,1599,640],{"class":376},[293,1601,1362],{"class":302},[293,1603,1604,1607,1610,1613],{"class":75,"line":357},[293,1605,1606],{"class":463},"    @event.listens_for",[293,1608,1609],{"class":302},"(engine.sync_engine, ",[293,1611,1612],{"class":360},"\"connect\"",[293,1614,410],{"class":302},[293,1616,1617,1620,1623],{"class":75,"line":367},[293,1618,1619],{"class":298},"    def",[293,1621,1622],{"class":463}," set_search_path",[293,1624,1625],{"class":302},"(dbapi_conn, connection_record):\n",[293,1627,1628,1631,1633],{"class":75,"line":381},[293,1629,1630],{"class":302},"        cursor ",[293,1632,351],{"class":298},[293,1634,1635],{"class":302}," dbapi_conn.cursor()\n",[293,1637,1638],{"class":75,"line":394},[293,1639,1640],{"class":609},"        # Parameterized SET is not supported — validate schema name before use\n",[293,1642,1643,1646,1649,1652,1654,1657,1660,1663],{"class":75,"line":407},[293,1644,1645],{"class":302},"        cursor.execute(",[293,1647,1648],{"class":298},"f",[293,1650,1651],{"class":360},"\"SET search_path = ",[293,1653,800],{"class":376},[293,1655,1656],{"class":302},"schema",[293,1658,1659],{"class":376},"}",[293,1661,1662],{"class":360},", public\"",[293,1664,410],{"class":302},[293,1666,1667],{"class":75,"line":413},[293,1668,1669],{"class":302},"        cursor.close()\n",[14,1671,1672,1673,1675,1676,1678],{},"For connection pooling environments, prefer ",[267,1674,108],{}," (which rewrites SQL at the SQLAlchemy layer) over ",[267,1677,171],{}," (which depends on connection-level state that pools reset unpredictably).",[1680,1681,1683],"h3",{"id":1682},"resetting-search_path-safely-on-connection-checkout","Resetting search_path Safely on Connection Checkout",[14,1685,1686,1687,1689,1690,1693],{},"If your deployment constraints force you to use ",[267,1688,171],{}," — for example, because third-party tools generate raw SQL that cannot be routed through SQLAlchemy — you can reset it safely by listening to the ",[267,1691,1692],{},"checkout"," pool event and issuing a reset statement before returning the connection to application code:",[284,1695,1697],{"className":286,"code":1696,"language":288,"meta":289,"style":289},"from sqlalchemy import event\nfrom sqlalchemy.pool import ConnectionPoolEntry\n\n\ndef register_search_path_reset(engine):\n    @event.listens_for(engine.sync_engine, \"checkout\")\n    def reset_search_path(dbapi_conn, connection_record: ConnectionPoolEntry, connection_proxy):\n        cursor = dbapi_conn.cursor()\n        # Reset to public to prevent prior tenant schema from leaking\n        cursor.execute(\"SET search_path = public\")\n        cursor.close()\n",[267,1698,1699,1709,1721,1725,1729,1739,1750,1760,1768,1773,1782],{"__ignoreMap":289},[293,1700,1701,1703,1705,1707],{"class":75,"line":295},[293,1702,299],{"class":298},[293,1704,303],{"class":302},[293,1706,306],{"class":298},[293,1708,1563],{"class":302},[293,1710,1711,1713,1716,1718],{"class":75,"line":312},[293,1712,299],{"class":298},[293,1714,1715],{"class":302}," sqlalchemy.pool ",[293,1717,306],{"class":298},[293,1719,1720],{"class":302}," ConnectionPoolEntry\n",[293,1722,1723],{"class":75,"line":325},[293,1724,342],{"emptyLinePlaceholder":341},[293,1726,1727],{"class":75,"line":338},[293,1728,342],{"emptyLinePlaceholder":341},[293,1730,1731,1733,1736],{"class":75,"line":345},[293,1732,1351],{"class":298},[293,1734,1735],{"class":463}," register_search_path_reset",[293,1737,1738],{"class":302},"(engine):\n",[293,1740,1741,1743,1745,1748],{"class":75,"line":357},[293,1742,1606],{"class":463},[293,1744,1609],{"class":302},[293,1746,1747],{"class":360},"\"checkout\"",[293,1749,410],{"class":302},[293,1751,1752,1754,1757],{"class":75,"line":367},[293,1753,1619],{"class":298},[293,1755,1756],{"class":463}," reset_search_path",[293,1758,1759],{"class":302},"(dbapi_conn, connection_record: ConnectionPoolEntry, connection_proxy):\n",[293,1761,1762,1764,1766],{"class":75,"line":381},[293,1763,1630],{"class":302},[293,1765,351],{"class":298},[293,1767,1635],{"class":302},[293,1769,1770],{"class":75,"line":394},[293,1771,1772],{"class":609},"        # Reset to public to prevent prior tenant schema from leaking\n",[293,1774,1775,1777,1780],{"class":75,"line":407},[293,1776,1645],{"class":302},[293,1778,1779],{"class":360},"\"SET search_path = public\"",[293,1781,410],{"class":302},[293,1783,1784],{"class":75,"line":413},[293,1785,1669],{"class":302},[14,1787,1788],{},"This adds one round-trip per connection checkout. For high-throughput workloads (thousands of requests per second), the overhead is measurable. Benchmark both approaches against your actual query mix before choosing.",[244,1790,1792],{"id":1791},"advanced-multi-tenant-patterns","Advanced Multi-Tenant Patterns",[1680,1794,1796],{"id":1795},"schema-per-tenant-vs-database-per-tenant","Schema-per-Tenant vs Database-per-Tenant",[14,1798,1799],{},"The choice between these two isolation models is primarily operational, not a SQLAlchemy API constraint:",[843,1801,1802,1815],{},[846,1803,1804],{},[849,1805,1806,1809,1812],{},[852,1807,1808],{},"Dimension",[852,1810,1811],{},"Schema-per-tenant",[852,1813,1814],{},"Database-per-tenant",[865,1816,1817,1828,1839,1850,1863],{},[849,1818,1819,1822,1825],{},[870,1820,1821],{},"Isolation",[870,1823,1824],{},"Logical (same DB process)",[870,1826,1827],{},"Physical (separate server or cluster)",[849,1829,1830,1833,1836],{},[870,1831,1832],{},"Pool cost",[870,1834,1835],{},"One engine, one pool, N schemas",[870,1837,1838],{},"One engine per tenant, N pools",[849,1840,1841,1844,1847],{},[870,1842,1843],{},"Cross-tenant queries",[870,1845,1846],{},"Possible via qualified names",[870,1848,1849],{},"Impossible without external tools",[849,1851,1852,1855,1860],{},[870,1853,1854],{},"Alembic migrations",[870,1856,1857,1858],{},"Run with ",[267,1859,108],{},[870,1861,1862],{},"Run per-database connection string",[849,1864,1865,1868,1871],{},[870,1866,1867],{},"Best for",[870,1869,1870],{},"SaaS with dozens–hundreds of tenants",[870,1872,1873],{},"Regulated industries, very large tenants",[14,1875,1876,1877,1880,1881,1883,1884,1888],{},"For schema-per-tenant with Alembic, configure ",[267,1878,1879],{},"env.py"," to iterate tenant schemas and apply ",[267,1882,108],{}," per migration run. This is covered in depth in the guide on ",[18,1885,1887],{"href":1886},"\u002Falembic-async-migrations-and-schema-evolution\u002Fconfiguring-alembic-with-async-sqlalchemy-engines\u002F","Configuring Alembic with Async SQLAlchemy Engines",".",[1680,1890,1892],{"id":1891},"per-tenant-database-with-engine-registry","Per-Tenant Database with Engine Registry",[14,1894,1895],{},"When tenants require full database isolation, maintain a registry of engines keyed by tenant identifier. Async engines are lightweight — the connection pool does not open connections until the first query — so pre-building the registry at startup is safe:",[284,1897,1899],{"className":286,"code":1898,"language":288,"meta":289,"style":289},"from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine\nfrom typing import dict as Dict\n\n_engine_registry: Dict[str, AsyncEngine] = {}\n\n\ndef get_or_create_engine(tenant_id: str, dsn: str) -> AsyncEngine:\n    if tenant_id not in _engine_registry:\n        _engine_registry[tenant_id] = create_async_engine(\n            dsn,\n            pool_size=5,          # per-tenant pool — keep small\n            max_overflow=2,\n            pool_recycle=1800,\n            pool_pre_ping=True,\n        )\n    return _engine_registry[tenant_id]\n\n\nasync def with_tenant_engine(tenant_id: str, dsn: str):\n    engine = get_or_create_engine(tenant_id, dsn)\n    async with engine.connect() as conn:\n        async with AsyncSession(bind=conn) as session:\n            yield session\n",[267,1900,1901,1912,1930,1934,1949,1953,1957,1977,1994,2003,2008,2024,2036,2048,2059,2064,2071,2075,2079,2098,2108,2121,2141],{"__ignoreMap":289},[293,1902,1903,1905,1907,1909],{"class":75,"line":295},[293,1904,299],{"class":298},[293,1906,317],{"class":302},[293,1908,306],{"class":298},[293,1910,1911],{"class":302}," AsyncEngine, create_async_engine\n",[293,1913,1914,1916,1919,1921,1924,1927],{"class":75,"line":312},[293,1915,299],{"class":298},[293,1917,1918],{"class":302}," typing ",[293,1920,306],{"class":298},[293,1922,1923],{"class":376}," dict",[293,1925,1926],{"class":298}," as",[293,1928,1929],{"class":302}," Dict\n",[293,1931,1932],{"class":75,"line":325},[293,1933,342],{"emptyLinePlaceholder":341},[293,1935,1936,1939,1941,1944,1946],{"class":75,"line":338},[293,1937,1938],{"class":302},"_engine_registry: Dict[",[293,1940,553],{"class":376},[293,1942,1943],{"class":302},", AsyncEngine] ",[293,1945,351],{"class":298},[293,1947,1948],{"class":302}," {}\n",[293,1950,1951],{"class":75,"line":345},[293,1952,342],{"emptyLinePlaceholder":341},[293,1954,1955],{"class":75,"line":357},[293,1956,342],{"emptyLinePlaceholder":341},[293,1958,1959,1961,1964,1967,1969,1972,1974],{"class":75,"line":367},[293,1960,1351],{"class":298},[293,1962,1963],{"class":463}," get_or_create_engine",[293,1965,1966],{"class":302},"(tenant_id: ",[293,1968,553],{"class":376},[293,1970,1971],{"class":302},", dsn: ",[293,1973,553],{"class":376},[293,1975,1976],{"class":302},") -> AsyncEngine:\n",[293,1978,1979,1982,1985,1988,1991],{"class":75,"line":381},[293,1980,1981],{"class":298},"    if",[293,1983,1984],{"class":302}," tenant_id ",[293,1986,1987],{"class":298},"not",[293,1989,1990],{"class":298}," in",[293,1992,1993],{"class":302}," _engine_registry:\n",[293,1995,1996,1999,2001],{"class":75,"line":394},[293,1997,1998],{"class":302},"        _engine_registry[tenant_id] ",[293,2000,351],{"class":298},[293,2002,354],{"class":302},[293,2004,2005],{"class":75,"line":407},[293,2006,2007],{"class":302},"            dsn,\n",[293,2009,2010,2013,2015,2018,2021],{"class":75,"line":413},[293,2011,2012],{"class":370},"            pool_size",[293,2014,351],{"class":298},[293,2016,2017],{"class":376},"5",[293,2019,2020],{"class":302},",          ",[293,2022,2023],{"class":609},"# per-tenant pool — keep small\n",[293,2025,2026,2029,2031,2034],{"class":75,"line":418},[293,2027,2028],{"class":370},"            max_overflow",[293,2030,351],{"class":298},[293,2032,2033],{"class":376},"2",[293,2035,364],{"class":302},[293,2037,2038,2041,2043,2046],{"class":75,"line":447},[293,2039,2040],{"class":370},"            pool_recycle",[293,2042,351],{"class":298},[293,2044,2045],{"class":376},"1800",[293,2047,364],{"class":302},[293,2049,2050,2053,2055,2057],{"class":75,"line":452},[293,2051,2052],{"class":370},"            pool_pre_ping",[293,2054,351],{"class":298},[293,2056,402],{"class":376},[293,2058,364],{"class":302},[293,2060,2061],{"class":75,"line":457},[293,2062,2063],{"class":302},"        )\n",[293,2065,2066,2068],{"class":75,"line":476},[293,2067,821],{"class":298},[293,2069,2070],{"class":302}," _engine_registry[tenant_id]\n",[293,2072,2073],{"class":75,"line":482},[293,2074,342],{"emptyLinePlaceholder":341},[293,2076,2077],{"class":75,"line":487},[293,2078,342],{"emptyLinePlaceholder":341},[293,2080,2081,2083,2085,2088,2090,2092,2094,2096],{"class":75,"line":492},[293,2082,727],{"class":298},[293,2084,730],{"class":298},[293,2086,2087],{"class":463}," with_tenant_engine",[293,2089,1966],{"class":302},[293,2091,553],{"class":376},[293,2093,1971],{"class":302},[293,2095,553],{"class":376},[293,2097,473],{"class":302},[293,2099,2100,2103,2105],{"class":75,"line":507},[293,2101,2102],{"class":302},"    engine ",[293,2104,351],{"class":298},[293,2106,2107],{"class":302}," get_or_create_engine(tenant_id, dsn)\n",[293,2109,2110,2112,2114,2117,2119],{"class":75,"line":518},[293,2111,1472],{"class":298},[293,2113,1475],{"class":298},[293,2115,2116],{"class":302}," engine.connect() ",[293,2118,1238],{"class":298},[293,2120,1241],{"class":302},[293,2122,2123,2125,2127,2130,2132,2134,2137,2139],{"class":75,"line":547},[293,2124,1487],{"class":298},[293,2126,1475],{"class":298},[293,2128,2129],{"class":302}," AsyncSession(",[293,2131,1278],{"class":370},[293,2133,351],{"class":298},[293,2135,2136],{"class":302},"conn) ",[293,2138,1238],{"class":298},[293,2140,1288],{"class":302},[293,2142,2143,2145],{"class":75,"line":571},[293,2144,1516],{"class":298},[293,2146,1519],{"class":302},[1680,2148,2150],{"id":2149},"routing-reads-to-replicas","Routing Reads to Replicas",[14,2152,2153,2154,2158,2159,2162],{},"Separating read and write workloads across async engines is the second dimension of multi-tenant routing. The ",[18,2155,2157],{"href":2156},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing\u002Frouting-reads-to-replicas-with-async-engines\u002F","routing reads to replicas guide"," covers ",[267,2160,2161],{},"Session.get_bind()"," overrides and async routing session factories in detail.",[14,2164,2165,2166,2168],{},"The foundation is two distinct engines — one targeting the primary, one targeting a read replica — combined with a routing ",[267,2167,928],{}," subclass that dispatches based on whether the operation is a write or a read:",[284,2170,2172],{"className":286,"code":2171,"language":288,"meta":289,"style":289},"from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\n\nprimary_engine = create_async_engine(\n    \"postgresql+asyncpg:\u002F\u002Fuser:pass@primary-host\u002Fsaas_db\",\n    pool_size=20,\n    max_overflow=10,\n)\n\nreplica_engine = create_async_engine(\n    \"postgresql+asyncpg:\u002F\u002Fuser:pass@replica-host\u002Fsaas_db\",\n    pool_size=30,   # replicas typically handle more concurrent reads\n    max_overflow=15,\n    pool_pre_ping=True,\n)\n",[267,2173,2174,2185,2189,2198,2205,2215,2225,2229,2233,2242,2249,2263,2274,2284],{"__ignoreMap":289},[293,2175,2176,2178,2180,2182],{"class":75,"line":295},[293,2177,299],{"class":298},[293,2179,317],{"class":302},[293,2181,306],{"class":298},[293,2183,2184],{"class":302}," AsyncSession, create_async_engine\n",[293,2186,2187],{"class":75,"line":312},[293,2188,342],{"emptyLinePlaceholder":341},[293,2190,2191,2194,2196],{"class":75,"line":325},[293,2192,2193],{"class":302},"primary_engine ",[293,2195,351],{"class":298},[293,2197,354],{"class":302},[293,2199,2200,2203],{"class":75,"line":338},[293,2201,2202],{"class":360},"    \"postgresql+asyncpg:\u002F\u002Fuser:pass@primary-host\u002Fsaas_db\"",[293,2204,364],{"class":302},[293,2206,2207,2209,2211,2213],{"class":75,"line":345},[293,2208,371],{"class":370},[293,2210,351],{"class":298},[293,2212,54],{"class":376},[293,2214,364],{"class":302},[293,2216,2217,2219,2221,2223],{"class":75,"line":357},[293,2218,384],{"class":370},[293,2220,351],{"class":298},[293,2222,389],{"class":376},[293,2224,364],{"class":302},[293,2226,2227],{"class":75,"line":367},[293,2228,410],{"class":302},[293,2230,2231],{"class":75,"line":381},[293,2232,342],{"emptyLinePlaceholder":341},[293,2234,2235,2238,2240],{"class":75,"line":394},[293,2236,2237],{"class":302},"replica_engine ",[293,2239,351],{"class":298},[293,2241,354],{"class":302},[293,2243,2244,2247],{"class":75,"line":407},[293,2245,2246],{"class":360},"    \"postgresql+asyncpg:\u002F\u002Fuser:pass@replica-host\u002Fsaas_db\"",[293,2248,364],{"class":302},[293,2250,2251,2253,2255,2257,2260],{"class":75,"line":413},[293,2252,371],{"class":370},[293,2254,351],{"class":298},[293,2256,161],{"class":376},[293,2258,2259],{"class":302},",   ",[293,2261,2262],{"class":609},"# replicas typically handle more concurrent reads\n",[293,2264,2265,2267,2269,2272],{"class":75,"line":418},[293,2266,384],{"class":370},[293,2268,351],{"class":298},[293,2270,2271],{"class":376},"15",[293,2273,364],{"class":302},[293,2275,2276,2278,2280,2282],{"class":75,"line":447},[293,2277,397],{"class":370},[293,2279,351],{"class":298},[293,2281,402],{"class":376},[293,2283,364],{"class":302},[293,2285,2286],{"class":75,"line":452},[293,2287,410],{"class":302},[1680,2289,2291],{"id":2290},"vertical-partitioning-across-multiple-engines","Vertical Partitioning Across Multiple Engines",[14,2293,2294,2295,2298,2299,2302,2303,2306,2307,2310,2311,2314,2315,1309],{},"Vertical partitioning means different ORM models connect to different databases — ",[267,2296,2297],{},"Order"," and ",[267,2300,2301],{},"Invoice"," live on a transactional Postgres cluster while ",[267,2304,2305],{},"AuditLog"," targets a separate analytics store. SQLAlchemy 2.0 dropped the old ",[267,2308,2309],{},"Session(binds={Model: engine})"," API; the modern approach uses a custom ",[267,2312,2313],{},"Session"," subclass overriding ",[267,2316,2317],{},"get_bind()",[284,2319,2321],{"className":286,"code":2320,"language":288,"meta":289,"style":289},"from sqlalchemy.orm import Session\nfrom sqlalchemy import inspect\n\n\nclass VerticallyPartitionedSession(Session):\n    _analytics_tables = frozenset([\"audit_logs\", \"metric_snapshots\"])\n\n    def get_bind(self, mapper=None, clause=None, **kwargs):\n        if mapper is not None:\n            table_name = mapper.persist_selectable.name\n            if table_name in self._analytics_tables:\n                return analytics_sync_engine\n        return primary_sync_engine\n",[267,2322,2323,2333,2344,2348,2352,2365,2389,2393,2422,2441,2451,2467,2475],{"__ignoreMap":289},[293,2324,2325,2327,2329,2331],{"class":75,"line":295},[293,2326,299],{"class":298},[293,2328,330],{"class":302},[293,2330,306],{"class":298},[293,2332,1204],{"class":302},[293,2334,2335,2337,2339,2341],{"class":75,"line":312},[293,2336,299],{"class":298},[293,2338,303],{"class":302},[293,2340,306],{"class":298},[293,2342,2343],{"class":302}," inspect\n",[293,2345,2346],{"class":75,"line":325},[293,2347,342],{"emptyLinePlaceholder":341},[293,2349,2350],{"class":75,"line":338},[293,2351,342],{"emptyLinePlaceholder":341},[293,2353,2354,2356,2359,2361,2363],{"class":75,"line":345},[293,2355,460],{"class":298},[293,2357,2358],{"class":463}," VerticallyPartitionedSession",[293,2360,467],{"class":302},[293,2362,2313],{"class":463},[293,2364,473],{"class":302},[293,2366,2367,2370,2372,2375,2378,2381,2383,2386],{"class":75,"line":357},[293,2368,2369],{"class":302},"    _analytics_tables ",[293,2371,351],{"class":298},[293,2373,2374],{"class":376}," frozenset",[293,2376,2377],{"class":302},"([",[293,2379,2380],{"class":360},"\"audit_logs\"",[293,2382,1383],{"class":302},[293,2384,2385],{"class":360},"\"metric_snapshots\"",[293,2387,2388],{"class":302},"])\n",[293,2390,2391],{"class":75,"line":367},[293,2392,342],{"emptyLinePlaceholder":341},[293,2394,2395,2397,2400,2403,2405,2407,2410,2412,2414,2416,2419],{"class":75,"line":381},[293,2396,1619],{"class":298},[293,2398,2399],{"class":463}," get_bind",[293,2401,2402],{"class":302},"(self, mapper",[293,2404,351],{"class":298},[293,2406,640],{"class":376},[293,2408,2409],{"class":302},", clause",[293,2411,351],{"class":298},[293,2413,640],{"class":376},[293,2415,1383],{"class":302},[293,2417,2418],{"class":298},"**",[293,2420,2421],{"class":302},"kwargs):\n",[293,2423,2424,2427,2430,2433,2436,2439],{"class":75,"line":394},[293,2425,2426],{"class":298},"        if",[293,2428,2429],{"class":302}," mapper ",[293,2431,2432],{"class":298},"is",[293,2434,2435],{"class":298}," not",[293,2437,2438],{"class":376}," None",[293,2440,1362],{"class":302},[293,2442,2443,2446,2448],{"class":75,"line":407},[293,2444,2445],{"class":302},"            table_name ",[293,2447,351],{"class":298},[293,2449,2450],{"class":302}," mapper.persist_selectable.name\n",[293,2452,2453,2456,2459,2461,2464],{"class":75,"line":413},[293,2454,2455],{"class":298},"            if",[293,2457,2458],{"class":302}," table_name ",[293,2460,1163],{"class":298},[293,2462,2463],{"class":376}," self",[293,2465,2466],{"class":302},"._analytics_tables:\n",[293,2468,2469,2472],{"class":75,"line":418},[293,2470,2471],{"class":298},"                return",[293,2473,2474],{"class":302}," analytics_sync_engine\n",[293,2476,2477,2480],{"class":75,"line":447},[293,2478,2479],{"class":298},"        return",[293,2481,2482],{"class":302}," primary_sync_engine\n",[14,2484,2485,2486,2488,2489,1309],{},"For async workflows, wrap this in an ",[267,2487,928],{}," with ",[267,2490,2491],{},"sync_session_class",[284,2493,2495],{"className":286,"code":2494,"language":288,"meta":289,"style":289},"from sqlalchemy.ext.asyncio import AsyncSession\n\nanalytics_session = AsyncSession(\n    sync_session_class=VerticallyPartitionedSession\n)\n",[267,2496,2497,2508,2512,2522,2532],{"__ignoreMap":289},[293,2498,2499,2501,2503,2505],{"class":75,"line":295},[293,2500,299],{"class":298},[293,2502,317],{"class":302},[293,2504,306],{"class":298},[293,2506,2507],{"class":302}," AsyncSession\n",[293,2509,2510],{"class":75,"line":312},[293,2511,342],{"emptyLinePlaceholder":341},[293,2513,2514,2517,2519],{"class":75,"line":325},[293,2515,2516],{"class":302},"analytics_session ",[293,2518,351],{"class":298},[293,2520,2521],{"class":302}," AsyncSession(\n",[293,2523,2524,2527,2529],{"class":75,"line":338},[293,2525,2526],{"class":370},"    sync_session_class",[293,2528,351],{"class":298},[293,2530,2531],{"class":302},"VerticallyPartitionedSession\n",[293,2533,2534],{"class":75,"line":345},[293,2535,410],{"class":302},[1680,2537,2539],{"id":2538},"tenant-schema-provisioning-and-teardown","Tenant Schema Provisioning and Teardown",[14,2541,2542,2543,2546],{},"Provisioning a new tenant schema at runtime requires DDL operations that must be executed outside a transaction in PostgreSQL (or within an advisory lock). Use ",[267,2544,2545],{},"text()"," with autocommit isolation to create the schema, then run Alembic migrations to build the table structure:",[284,2548,2550],{"className":286,"code":2549,"language":288,"meta":289,"style":289},"from sqlalchemy import text\nfrom sqlalchemy.ext.asyncio import AsyncConnection\n\n\nasync def provision_tenant_schema(conn: AsyncConnection, schema_name: str) -> None:\n    # Validate schema_name before any SQL — allowlist regex or lookup\n    import re\n    if not re.fullmatch(r\"[a-z][a-z0-9_]{0,62}\", schema_name):\n        raise ValueError(f\"Invalid schema name: {schema_name!r}\")\n\n    # CREATE SCHEMA IF NOT EXISTS must run outside a transaction block\n    await conn.execute(text(\"COMMIT\"))  # end any implicit transaction\n    await conn.execute(text(f\"CREATE SCHEMA IF NOT EXISTS {schema_name}\"))\n    # Grant application user access to new schema\n    await conn.execute(\n        text(f\"GRANT USAGE, CREATE ON SCHEMA {schema_name} TO app_user\")\n    )\n",[267,2551,2552,2563,2574,2578,2582,2602,2607,2615,2641,2670,2674,2679,2696,2718,2723,2730,2751],{"__ignoreMap":289},[293,2553,2554,2556,2558,2560],{"class":75,"line":295},[293,2555,299],{"class":298},[293,2557,303],{"class":302},[293,2559,306],{"class":298},[293,2561,2562],{"class":302}," text\n",[293,2564,2565,2567,2569,2571],{"class":75,"line":312},[293,2566,299],{"class":298},[293,2568,317],{"class":302},[293,2570,306],{"class":298},[293,2572,2573],{"class":302}," AsyncConnection\n",[293,2575,2576],{"class":75,"line":325},[293,2577,342],{"emptyLinePlaceholder":341},[293,2579,2580],{"class":75,"line":338},[293,2581,342],{"emptyLinePlaceholder":341},[293,2583,2584,2586,2588,2591,2594,2596,2598,2600],{"class":75,"line":345},[293,2585,727],{"class":298},[293,2587,730],{"class":298},[293,2589,2590],{"class":463}," provision_tenant_schema",[293,2592,2593],{"class":302},"(conn: AsyncConnection, schema_name: ",[293,2595,553],{"class":376},[293,2597,1597],{"class":302},[293,2599,640],{"class":376},[293,2601,1362],{"class":302},[293,2603,2604],{"class":75,"line":357},[293,2605,2606],{"class":609},"    # Validate schema_name before any SQL — allowlist regex or lookup\n",[293,2608,2609,2612],{"class":75,"line":367},[293,2610,2611],{"class":298},"    import",[293,2613,2614],{"class":302}," re\n",[293,2616,2617,2619,2621,2624,2627,2630,2633,2636,2638],{"class":75,"line":381},[293,2618,1981],{"class":298},[293,2620,2435],{"class":298},[293,2622,2623],{"class":302}," re.fullmatch(",[293,2625,2626],{"class":298},"r",[293,2628,2629],{"class":360},"\"",[293,2631,2632],{"class":376},"[a-z][a-z0-9_]",[293,2634,2635],{"class":298},"{0,62}",[293,2637,2629],{"class":360},[293,2639,2640],{"class":302},", schema_name):\n",[293,2642,2643,2646,2649,2651,2653,2656,2658,2661,2664,2666,2668],{"class":75,"line":394},[293,2644,2645],{"class":298},"        raise",[293,2647,2648],{"class":376}," ValueError",[293,2650,467],{"class":302},[293,2652,1648],{"class":298},[293,2654,2655],{"class":360},"\"Invalid schema name: ",[293,2657,800],{"class":376},[293,2659,2660],{"class":302},"schema_name",[293,2662,2663],{"class":298},"!r",[293,2665,1659],{"class":376},[293,2667,2629],{"class":360},[293,2669,410],{"class":302},[293,2671,2672],{"class":75,"line":407},[293,2673,342],{"emptyLinePlaceholder":341},[293,2675,2676],{"class":75,"line":413},[293,2677,2678],{"class":609},"    # CREATE SCHEMA IF NOT EXISTS must run outside a transaction block\n",[293,2680,2681,2684,2687,2690,2693],{"class":75,"line":418},[293,2682,2683],{"class":298},"    await",[293,2685,2686],{"class":302}," conn.execute(text(",[293,2688,2689],{"class":360},"\"COMMIT\"",[293,2691,2692],{"class":302},"))  ",[293,2694,2695],{"class":609},"# end any implicit transaction\n",[293,2697,2698,2700,2702,2704,2707,2709,2711,2713,2715],{"class":75,"line":447},[293,2699,2683],{"class":298},[293,2701,2686],{"class":302},[293,2703,1648],{"class":298},[293,2705,2706],{"class":360},"\"CREATE SCHEMA IF NOT EXISTS ",[293,2708,800],{"class":376},[293,2710,2660],{"class":302},[293,2712,1659],{"class":376},[293,2714,2629],{"class":360},[293,2716,2717],{"class":302},"))\n",[293,2719,2720],{"class":75,"line":452},[293,2721,2722],{"class":609},"    # Grant application user access to new schema\n",[293,2724,2725,2727],{"class":75,"line":457},[293,2726,2683],{"class":298},[293,2728,2729],{"class":302}," conn.execute(\n",[293,2731,2732,2735,2737,2740,2742,2744,2746,2749],{"class":75,"line":476},[293,2733,2734],{"class":302},"        text(",[293,2736,1648],{"class":298},[293,2738,2739],{"class":360},"\"GRANT USAGE, CREATE ON SCHEMA ",[293,2741,800],{"class":376},[293,2743,2660],{"class":302},[293,2745,1659],{"class":376},[293,2747,2748],{"class":360}," TO app_user\"",[293,2750,410],{"class":302},[293,2752,2753],{"class":75,"line":482},[293,2754,816],{"class":302},[14,2756,2757,2758,2760,2761,2763,2764,2766],{},"After schema creation, run Alembic programmatically by setting ",[267,2759,108],{}," in the Alembic ",[267,2762,1879],{}," context, targeting the new schema name. Refer to ",[18,2765,1887],{"href":1886}," for the full env.py wiring.",[14,2768,2769,2770,2773,2774,2777],{},"Teardown (offboarding a tenant) requires the reverse: ",[267,2771,2772],{},"DROP SCHEMA IF EXISTS {schema_name} CASCADE",". Because this operation is irreversible, always snapshot the schema to an external data store before executing. Implement a two-phase process — first rename the schema to ",[267,2775,2776],{},"{schema_name}_deleted_YYYYMMDD",", confirm data export after a grace period, then drop.",[244,2779,2781],{"id":2780},"hybrid-architectures-migration-strategies","Hybrid Architectures & Migration Strategies",[1680,2783,2785,2786],{"id":2784},"migrating-from-sqlalchemy-14-sessionbinds","Migrating from SQLAlchemy 1.4 ",[267,2787,2788],{},"Session(binds=...)",[14,2790,2791,2792,2795],{},"SQLAlchemy 1.4 supported ",[267,2793,2794],{},"Session(binds={SomeModel: some_engine, some_table: another_engine})"," at the session constructor level. This API was removed in 2.0. The migration path:",[2797,2798,2799,2806,2813,2822],"ol",{},[2800,2801,2802,2803,2805],"li",{},"Identify all ",[267,2804,2788],{}," callsites.",[2800,2807,2808,2809,2812],{},"For each mapped class in the old ",[267,2810,2811],{},"binds"," dict, determine its table name.",[2800,2814,2815,2816,2818,2819,2821],{},"Implement ",[267,2817,2317],{}," in a ",[267,2820,2313],{}," subclass with equivalent routing logic.",[2800,2823,2824,2825,2488,2827,1888],{},"Replace ",[267,2826,2788],{},[267,2828,2829],{},"MyRoutingSession(...)",[14,2831,2832,2833,2835,2836,2838],{},"For async applications, the ",[267,2834,2491],{}," parameter on ",[267,2837,928],{}," is the bridge:",[284,2840,2842],{"className":286,"code":2841,"language":288,"meta":289,"style":289},"# 1.4 pattern (removed in 2.0)\n# session = Session(binds={Order: order_engine, AuditLog: audit_engine})\n\n# 2.0 pattern\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\n\nclass RoutingSession(Session):\n    def get_bind(self, mapper=None, clause=None, **kwargs):\n        if mapper and mapper.class_.__tablename__ in (\"audit_logs\",):\n            return audit_sync_engine\n        return order_sync_engine\n\n\nasync_session = AsyncSession(sync_session_class=RoutingSession)\n",[267,2843,2844,2849,2854,2858,2863,2873,2877,2881,2894,2918,2940,2948,2955,2959,2963],{"__ignoreMap":289},[293,2845,2846],{"class":75,"line":295},[293,2847,2848],{"class":609},"# 1.4 pattern (removed in 2.0)\n",[293,2850,2851],{"class":75,"line":312},[293,2852,2853],{"class":609},"# session = Session(binds={Order: order_engine, AuditLog: audit_engine})\n",[293,2855,2856],{"class":75,"line":325},[293,2857,342],{"emptyLinePlaceholder":341},[293,2859,2860],{"class":75,"line":338},[293,2861,2862],{"class":609},"# 2.0 pattern\n",[293,2864,2865,2867,2869,2871],{"class":75,"line":345},[293,2866,299],{"class":298},[293,2868,317],{"class":302},[293,2870,306],{"class":298},[293,2872,2507],{"class":302},[293,2874,2875],{"class":75,"line":357},[293,2876,342],{"emptyLinePlaceholder":341},[293,2878,2879],{"class":75,"line":367},[293,2880,342],{"emptyLinePlaceholder":341},[293,2882,2883,2885,2888,2890,2892],{"class":75,"line":381},[293,2884,460],{"class":298},[293,2886,2887],{"class":463}," RoutingSession",[293,2889,467],{"class":302},[293,2891,2313],{"class":463},[293,2893,473],{"class":302},[293,2895,2896,2898,2900,2902,2904,2906,2908,2910,2912,2914,2916],{"class":75,"line":394},[293,2897,1619],{"class":298},[293,2899,2399],{"class":463},[293,2901,2402],{"class":302},[293,2903,351],{"class":298},[293,2905,640],{"class":376},[293,2907,2409],{"class":302},[293,2909,351],{"class":298},[293,2911,640],{"class":376},[293,2913,1383],{"class":302},[293,2915,2418],{"class":298},[293,2917,2421],{"class":302},[293,2919,2920,2922,2924,2927,2930,2932,2935,2937],{"class":75,"line":407},[293,2921,2426],{"class":298},[293,2923,2429],{"class":302},[293,2925,2926],{"class":298},"and",[293,2928,2929],{"class":302}," mapper.class_.__tablename__ ",[293,2931,1163],{"class":298},[293,2933,2934],{"class":302}," (",[293,2936,2380],{"class":360},[293,2938,2939],{"class":302},",):\n",[293,2941,2942,2945],{"class":75,"line":413},[293,2943,2944],{"class":298},"            return",[293,2946,2947],{"class":302}," audit_sync_engine\n",[293,2949,2950,2952],{"class":75,"line":418},[293,2951,2479],{"class":298},[293,2953,2954],{"class":302}," order_sync_engine\n",[293,2956,2957],{"class":75,"line":447},[293,2958,342],{"emptyLinePlaceholder":341},[293,2960,2961],{"class":75,"line":452},[293,2962,342],{"emptyLinePlaceholder":341},[293,2964,2965,2967,2969,2971,2973,2975],{"class":75,"line":457},[293,2966,421],{"class":302},[293,2968,351],{"class":298},[293,2970,2129],{"class":302},[293,2972,2491],{"class":370},[293,2974,351],{"class":298},[293,2976,2977],{"class":302},"RoutingSession)\n",[1680,2979,2981],{"id":2980},"combining-schema-routing-with-read-replica-routing","Combining Schema Routing with Read Replica Routing",[14,2983,2984,2985,2987,2988,2990],{},"The most demanding production configuration combines both: route read queries to a replica AND apply ",[267,2986,108],{}," for tenant isolation. Stack both ",[267,2989,716],{}," at call time:",[284,2992,2994],{"className":286,"code":2993,"language":288,"meta":289,"style":289},"async def fetch_tenant_invoices_from_replica(\n    session: AsyncSession,\n    tenant_schema: str,\n) -> list[Invoice]:\n    stmt = select(Invoice).where(Invoice.paid == True)\n    result = await session.execute(\n        stmt,\n        execution_options={\n            \"schema_translate_map\": {None: tenant_schema},\n            # Custom flag read by a routing Session subclass\n            \"use_replica\": True,\n        },\n    )\n    return result.scalars().all()\n",[267,2995,2996,3007,3011,3019,3023,3038,3048,3052,3061,3073,3078,3089,3094,3098],{"__ignoreMap":289},[293,2997,2998,3000,3002,3005],{"class":75,"line":295},[293,2999,727],{"class":298},[293,3001,730],{"class":298},[293,3003,3004],{"class":463}," fetch_tenant_invoices_from_replica",[293,3006,736],{"class":302},[293,3008,3009],{"class":75,"line":312},[293,3010,955],{"class":302},[293,3012,3013,3015,3017],{"class":75,"line":325},[293,3014,960],{"class":302},[293,3016,553],{"class":376},[293,3018,364],{"class":302},[293,3020,3021],{"class":75,"line":338},[293,3022,749],{"class":302},[293,3024,3025,3027,3029,3031,3033,3036],{"class":75,"line":345},[293,3026,754],{"class":302},[293,3028,351],{"class":298},[293,3030,759],{"class":302},[293,3032,762],{"class":298},[293,3034,3035],{"class":376}," True",[293,3037,410],{"class":302},[293,3039,3040,3042,3044,3046],{"class":75,"line":357},[293,3041,777],{"class":302},[293,3043,351],{"class":298},[293,3045,782],{"class":298},[293,3047,785],{"class":302},[293,3049,3050],{"class":75,"line":367},[293,3051,790],{"class":302},[293,3053,3054,3056,3058],{"class":75,"line":381},[293,3055,795],{"class":370},[293,3057,351],{"class":298},[293,3059,3060],{"class":302},"{\n",[293,3062,3063,3066,3068,3070],{"class":75,"line":394},[293,3064,3065],{"class":360},"            \"schema_translate_map\"",[293,3067,806],{"class":302},[293,3069,640],{"class":376},[293,3071,3072],{"class":302},": tenant_schema},\n",[293,3074,3075],{"class":75,"line":407},[293,3076,3077],{"class":609},"            # Custom flag read by a routing Session subclass\n",[293,3079,3080,3083,3085,3087],{"class":75,"line":413},[293,3081,3082],{"class":360},"            \"use_replica\"",[293,3084,637],{"class":302},[293,3086,402],{"class":376},[293,3088,364],{"class":302},[293,3090,3091],{"class":75,"line":418},[293,3092,3093],{"class":302},"        },\n",[293,3095,3096],{"class":75,"line":447},[293,3097,816],{"class":302},[293,3099,3100,3102],{"class":75,"line":452},[293,3101,821],{"class":298},[293,3103,824],{"class":302},[14,3105,3106,3107,3110,3111,3113,3114,3116],{},"The routing session inspects ",[267,3108,3109],{},"self._execution_options.get(\"use_replica\")"," inside ",[267,3112,2317],{}," to select the replica engine, while SQLAlchemy's built-in ",[267,3115,108],{}," handler rewrites the table names independently.",[14,3118,3119,3120,3122,3123,1888],{},"For the mechanics of ",[267,3121,108],{}," with FastAPI dependency injection, see the step-by-step walkthrough on ",[18,3124,3126],{"href":3125},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing\u002Fswitching-schemas-per-request-with-schema-translate-map\u002F","switching schemas per request",[1680,3128,3130],{"id":3129},"bulk-data-operations-in-multi-tenant-contexts","Bulk Data Operations in Multi-Tenant Contexts",[14,3132,3133,3134,3136,3137,2298,3140,3143,3144,2488,3148,3151],{},"Bulk insert and update operations — such as loading a CSV of orders into a tenant's schema — require the same ",[267,3135,108],{}," plumbing applied to ",[267,3138,3139],{},"insert()",[267,3141,3142],{},"update()"," statements. When using ",[18,3145,3147],{"href":3146},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fhigh-performance-bulk-inserts-and-updates\u002F","high-performance bulk inserts",[267,3149,3150],{},"session.execute(insert(Model), rows)",", the schema translation applies to the INSERT target table automatically:",[284,3153,3155],{"className":286,"code":3154,"language":288,"meta":289,"style":289},"from sqlalchemy import insert\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom your_models import Invoice\n\n\nasync def bulk_load_invoices(\n    session: AsyncSession,\n    tenant_schema: str,\n    rows: list[dict],\n) -> None:\n    stmt = insert(Invoice)\n    # Chunk to avoid parameter limit (asyncpg cap: ~32 767 params per statement)\n    chunk_size = 500\n    async with session.begin():\n        for i in range(0, len(rows), chunk_size):\n            await session.execute(\n                stmt,\n                rows[i : i + chunk_size],\n                execution_options={\"schema_translate_map\": {None: tenant_schema}},\n            )\n    # Emits: INSERT INTO acme.invoices (tenant_id, amount_cents, paid) VALUES ...\n",[267,3156,3157,3168,3178,3190,3194,3198,3209,3213,3221,3231,3239,3248,3253,3263,3271,3296,3303,3308,3319,3336,3341],{"__ignoreMap":289},[293,3158,3159,3161,3163,3165],{"class":75,"line":295},[293,3160,299],{"class":298},[293,3162,303],{"class":302},[293,3164,306],{"class":298},[293,3166,3167],{"class":302}," insert\n",[293,3169,3170,3172,3174,3176],{"class":75,"line":312},[293,3171,299],{"class":298},[293,3173,317],{"class":302},[293,3175,306],{"class":298},[293,3177,2507],{"class":302},[293,3179,3180,3182,3185,3187],{"class":75,"line":325},[293,3181,299],{"class":298},[293,3183,3184],{"class":302}," your_models ",[293,3186,306],{"class":298},[293,3188,3189],{"class":302}," Invoice\n",[293,3191,3192],{"class":75,"line":338},[293,3193,342],{"emptyLinePlaceholder":341},[293,3195,3196],{"class":75,"line":345},[293,3197,342],{"emptyLinePlaceholder":341},[293,3199,3200,3202,3204,3207],{"class":75,"line":357},[293,3201,727],{"class":298},[293,3203,730],{"class":298},[293,3205,3206],{"class":463}," bulk_load_invoices",[293,3208,736],{"class":302},[293,3210,3211],{"class":75,"line":367},[293,3212,955],{"class":302},[293,3214,3215,3217,3219],{"class":75,"line":381},[293,3216,960],{"class":302},[293,3218,553],{"class":376},[293,3220,364],{"class":302},[293,3222,3223,3226,3228],{"class":75,"line":394},[293,3224,3225],{"class":302},"    rows: list[",[293,3227,981],{"class":376},[293,3229,3230],{"class":302},"],\n",[293,3232,3233,3235,3237],{"class":75,"line":407},[293,3234,1597],{"class":302},[293,3236,640],{"class":376},[293,3238,1362],{"class":302},[293,3240,3241,3243,3245],{"class":75,"line":413},[293,3242,754],{"class":302},[293,3244,351],{"class":298},[293,3246,3247],{"class":302}," insert(Invoice)\n",[293,3249,3250],{"class":75,"line":418},[293,3251,3252],{"class":609},"    # Chunk to avoid parameter limit (asyncpg cap: ~32 767 params per statement)\n",[293,3254,3255,3258,3260],{"class":75,"line":447},[293,3256,3257],{"class":302},"    chunk_size ",[293,3259,351],{"class":298},[293,3261,3262],{"class":376}," 500\n",[293,3264,3265,3267,3269],{"class":75,"line":452},[293,3266,1472],{"class":298},[293,3268,1475],{"class":298},[293,3270,1492],{"class":302},[293,3272,3273,3276,3279,3281,3284,3286,3288,3290,3293],{"class":75,"line":457},[293,3274,3275],{"class":298},"        for",[293,3277,3278],{"class":302}," i ",[293,3280,1163],{"class":298},[293,3282,3283],{"class":376}," range",[293,3285,467],{"class":302},[293,3287,47],{"class":376},[293,3289,1383],{"class":302},[293,3291,3292],{"class":376},"len",[293,3294,3295],{"class":302},"(rows), chunk_size):\n",[293,3297,3298,3301],{"class":75,"line":476},[293,3299,3300],{"class":298},"            await",[293,3302,785],{"class":302},[293,3304,3305],{"class":75,"line":482},[293,3306,3307],{"class":302},"                stmt,\n",[293,3309,3310,3313,3316],{"class":75,"line":487},[293,3311,3312],{"class":302},"                rows[i : i ",[293,3314,3315],{"class":298},"+",[293,3317,3318],{"class":302}," chunk_size],\n",[293,3320,3321,3324,3326,3328,3330,3332,3334],{"class":75,"line":492},[293,3322,3323],{"class":370},"                execution_options",[293,3325,351],{"class":298},[293,3327,800],{"class":302},[293,3329,803],{"class":360},[293,3331,806],{"class":302},[293,3333,640],{"class":376},[293,3335,1131],{"class":302},[293,3337,3338],{"class":75,"line":507},[293,3339,3340],{"class":302},"            )\n",[293,3342,3343],{"class":75,"line":518},[293,3344,3345],{"class":609},"    # Emits: INSERT INTO acme.invoices (tenant_id, amount_cents, paid) VALUES ...\n",[14,3347,3348,3349,2488,3351,3354],{},"For streaming large per-tenant result sets back to callers, combine ",[267,3350,108],{},[267,3352,3353],{},"yield_per"," execution options. SQLAlchemy merges both options on the same statement without conflict.",[1680,3356,3358],{"id":3357},"testing-multi-tenant-schema-routing","Testing Multi-Tenant Schema Routing",[14,3360,3361],{},"Testing schema routing in CI requires either a real PostgreSQL instance (via Docker) or careful mocking. The recommended approach uses a dedicated test schema per test run, created and dropped around the test session:",[284,3363,3365],{"className":286,"code":3364,"language":288,"meta":289,"style":289},"import pytest\nimport pytest_asyncio\nfrom sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker\nfrom sqlalchemy import text\n\nTEST_SCHEMA = \"test_tenant_acme\"\n\n@pytest_asyncio.fixture(scope=\"session\")\nasync def test_engine():\n    engine = create_async_engine(\n        \"postgresql+asyncpg:\u002F\u002Fuser:pass@localhost\u002Ftest_db\",\n        echo=False,\n    )\n    async with engine.connect() as conn:\n        await conn.execute(text(\"COMMIT\"))\n        await conn.execute(text(f\"CREATE SCHEMA IF NOT EXISTS {TEST_SCHEMA}\"))\n        await conn.execute(text(\"COMMIT\"))\n    yield engine\n    async with engine.connect() as conn:\n        await conn.execute(text(\"COMMIT\"))\n        await conn.execute(text(f\"DROP SCHEMA IF EXISTS {TEST_SCHEMA} CASCADE\"))\n    await engine.dispose()\n\n\n@pytest.mark.asyncio\nasync def test_invoice_schema_routing(test_engine):\n    from sqlalchemy import insert, select\n    from your_models import Base, Invoice\n\n    async with test_engine.begin() as conn:\n        # Create tables in test schema using schema_translate_map\n        await conn.run_sync(\n            Base.metadata.create_all,\n            schema_translate_map={None: TEST_SCHEMA},\n        )\n\n    session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)\n    async with session_factory() as session:\n        # Insert into test schema\n        await session.execute(\n            insert(Invoice).values(tenant_id=1, amount_cents=1000, paid=False),\n            execution_options={\"schema_translate_map\": {None: TEST_SCHEMA}},\n        )\n        await session.commit()\n\n        # Read back from test schema\n        result = await session.execute(\n            select(Invoice),\n            execution_options={\"schema_translate_map\": {None: TEST_SCHEMA}},\n        )\n        invoices = result.scalars().all()\n        assert len(invoices) == 1\n        assert invoices[0].amount_cents == 1000\n",[267,3366,3367,3374,3381,3392,3402,3406,3417,3421,3438,3450,3458,3465,3476,3480,3492,3503,3520,3530,3538,3550,3560,3578,3585,3589,3593,3598,3610,3621,3632,3636,3649,3654,3661,3666,3685,3690,3695,3720,3734,3740,3747,3783,3806,3811,3819,3824,3830,3842,3848,3869,3874,3883,3900],{"__ignoreMap":289},[293,3368,3369,3371],{"class":75,"line":295},[293,3370,306],{"class":298},[293,3372,3373],{"class":302}," pytest\n",[293,3375,3376,3378],{"class":75,"line":312},[293,3377,306],{"class":298},[293,3379,3380],{"class":302}," pytest_asyncio\n",[293,3382,3383,3385,3387,3389],{"class":75,"line":325},[293,3384,299],{"class":298},[293,3386,317],{"class":302},[293,3388,306],{"class":298},[293,3390,3391],{"class":302}," create_async_engine, AsyncSession, async_sessionmaker\n",[293,3393,3394,3396,3398,3400],{"class":75,"line":338},[293,3395,299],{"class":298},[293,3397,303],{"class":302},[293,3399,306],{"class":298},[293,3401,2562],{"class":302},[293,3403,3404],{"class":75,"line":345},[293,3405,342],{"emptyLinePlaceholder":341},[293,3407,3408,3411,3414],{"class":75,"line":357},[293,3409,3410],{"class":376},"TEST_SCHEMA",[293,3412,3413],{"class":298}," =",[293,3415,3416],{"class":360}," \"test_tenant_acme\"\n",[293,3418,3419],{"class":75,"line":367},[293,3420,342],{"emptyLinePlaceholder":341},[293,3422,3423,3426,3428,3431,3433,3436],{"class":75,"line":381},[293,3424,3425],{"class":463},"@pytest_asyncio.fixture",[293,3427,467],{"class":302},[293,3429,3430],{"class":370},"scope",[293,3432,351],{"class":298},[293,3434,3435],{"class":360},"\"session\"",[293,3437,410],{"class":302},[293,3439,3440,3442,3444,3447],{"class":75,"line":394},[293,3441,727],{"class":298},[293,3443,730],{"class":298},[293,3445,3446],{"class":463}," test_engine",[293,3448,3449],{"class":302},"():\n",[293,3451,3452,3454,3456],{"class":75,"line":407},[293,3453,2102],{"class":302},[293,3455,351],{"class":298},[293,3457,354],{"class":302},[293,3459,3460,3463],{"class":75,"line":413},[293,3461,3462],{"class":360},"        \"postgresql+asyncpg:\u002F\u002Fuser:pass@localhost\u002Ftest_db\"",[293,3464,364],{"class":302},[293,3466,3467,3470,3472,3474],{"class":75,"line":418},[293,3468,3469],{"class":370},"        echo",[293,3471,351],{"class":298},[293,3473,442],{"class":376},[293,3475,364],{"class":302},[293,3477,3478],{"class":75,"line":447},[293,3479,816],{"class":302},[293,3481,3482,3484,3486,3488,3490],{"class":75,"line":452},[293,3483,1472],{"class":298},[293,3485,1475],{"class":298},[293,3487,2116],{"class":302},[293,3489,1238],{"class":298},[293,3491,1241],{"class":302},[293,3493,3494,3497,3499,3501],{"class":75,"line":457},[293,3495,3496],{"class":298},"        await",[293,3498,2686],{"class":302},[293,3500,2689],{"class":360},[293,3502,2717],{"class":302},[293,3504,3505,3507,3509,3511,3513,3516,3518],{"class":75,"line":476},[293,3506,3496],{"class":298},[293,3508,2686],{"class":302},[293,3510,1648],{"class":298},[293,3512,2706],{"class":360},[293,3514,3515],{"class":376},"{TEST_SCHEMA}",[293,3517,2629],{"class":360},[293,3519,2717],{"class":302},[293,3521,3522,3524,3526,3528],{"class":75,"line":482},[293,3523,3496],{"class":298},[293,3525,2686],{"class":302},[293,3527,2689],{"class":360},[293,3529,2717],{"class":302},[293,3531,3532,3535],{"class":75,"line":487},[293,3533,3534],{"class":298},"    yield",[293,3536,3537],{"class":302}," engine\n",[293,3539,3540,3542,3544,3546,3548],{"class":75,"line":492},[293,3541,1472],{"class":298},[293,3543,1475],{"class":298},[293,3545,2116],{"class":302},[293,3547,1238],{"class":298},[293,3549,1241],{"class":302},[293,3551,3552,3554,3556,3558],{"class":75,"line":507},[293,3553,3496],{"class":298},[293,3555,2686],{"class":302},[293,3557,2689],{"class":360},[293,3559,2717],{"class":302},[293,3561,3562,3564,3566,3568,3571,3573,3576],{"class":75,"line":518},[293,3563,3496],{"class":298},[293,3565,2686],{"class":302},[293,3567,1648],{"class":298},[293,3569,3570],{"class":360},"\"DROP SCHEMA IF EXISTS ",[293,3572,3515],{"class":376},[293,3574,3575],{"class":360}," CASCADE\"",[293,3577,2717],{"class":302},[293,3579,3580,3582],{"class":75,"line":547},[293,3581,2683],{"class":298},[293,3583,3584],{"class":302}," engine.dispose()\n",[293,3586,3587],{"class":75,"line":571},[293,3588,342],{"emptyLinePlaceholder":341},[293,3590,3591],{"class":75,"line":582},[293,3592,342],{"emptyLinePlaceholder":341},[293,3594,3595],{"class":75,"line":587},[293,3596,3597],{"class":463},"@pytest.mark.asyncio\n",[293,3599,3600,3602,3604,3607],{"class":75,"line":592},[293,3601,727],{"class":298},[293,3603,730],{"class":298},[293,3605,3606],{"class":463}," test_invoice_schema_routing",[293,3608,3609],{"class":302},"(test_engine):\n",[293,3611,3612,3614,3616,3618],{"class":75,"line":606},[293,3613,989],{"class":298},[293,3615,303],{"class":302},[293,3617,306],{"class":298},[293,3619,3620],{"class":302}," insert, select\n",[293,3622,3623,3625,3627,3629],{"class":75,"line":613},[293,3624,989],{"class":298},[293,3626,3184],{"class":302},[293,3628,306],{"class":298},[293,3630,3631],{"class":302}," Base, Invoice\n",[293,3633,3634],{"class":75,"line":623},[293,3635,342],{"emptyLinePlaceholder":341},[293,3637,3638,3640,3642,3645,3647],{"class":75,"line":646},[293,3639,1472],{"class":298},[293,3641,1475],{"class":298},[293,3643,3644],{"class":302}," test_engine.begin() ",[293,3646,1238],{"class":298},[293,3648,1241],{"class":302},[293,3650,3651],{"class":75,"line":669},[293,3652,3653],{"class":609},"        # Create tables in test schema using schema_translate_map\n",[293,3655,3656,3658],{"class":75,"line":679},[293,3657,3496],{"class":298},[293,3659,3660],{"class":302}," conn.run_sync(\n",[293,3662,3663],{"class":75,"line":689},[293,3664,3665],{"class":302},"            Base.metadata.create_all,\n",[293,3667,3669,3672,3674,3676,3678,3680,3682],{"class":75,"line":3668},34,[293,3670,3671],{"class":370},"            schema_translate_map",[293,3673,351],{"class":298},[293,3675,800],{"class":302},[293,3677,640],{"class":376},[293,3679,637],{"class":302},[293,3681,3410],{"class":376},[293,3683,3684],{"class":302},"},\n",[293,3686,3688],{"class":75,"line":3687},35,[293,3689,2063],{"class":302},[293,3691,3693],{"class":75,"line":3692},36,[293,3694,342],{"emptyLinePlaceholder":341},[293,3696,3698,3701,3703,3706,3708,3710,3712,3714,3716,3718],{"class":75,"line":3697},37,[293,3699,3700],{"class":302},"    session_factory ",[293,3702,351],{"class":298},[293,3704,3705],{"class":302}," async_sessionmaker(test_engine, ",[293,3707,429],{"class":370},[293,3709,351],{"class":298},[293,3711,434],{"class":302},[293,3713,437],{"class":370},[293,3715,351],{"class":298},[293,3717,442],{"class":376},[293,3719,410],{"class":302},[293,3721,3723,3725,3727,3730,3732],{"class":75,"line":3722},38,[293,3724,1472],{"class":298},[293,3726,1475],{"class":298},[293,3728,3729],{"class":302}," session_factory() ",[293,3731,1238],{"class":298},[293,3733,1288],{"class":302},[293,3735,3737],{"class":75,"line":3736},39,[293,3738,3739],{"class":609},"        # Insert into test schema\n",[293,3741,3743,3745],{"class":75,"line":3742},40,[293,3744,3496],{"class":298},[293,3746,785],{"class":302},[293,3748,3750,3753,3756,3758,3761,3763,3766,3768,3771,3773,3776,3778,3780],{"class":75,"line":3749},41,[293,3751,3752],{"class":302},"            insert(Invoice).values(",[293,3754,3755],{"class":370},"tenant_id",[293,3757,351],{"class":298},[293,3759,3760],{"class":376},"1",[293,3762,1383],{"class":302},[293,3764,3765],{"class":370},"amount_cents",[293,3767,351],{"class":298},[293,3769,3770],{"class":376},"1000",[293,3772,1383],{"class":302},[293,3774,3775],{"class":370},"paid",[293,3777,351],{"class":298},[293,3779,442],{"class":376},[293,3781,3782],{"class":302},"),\n",[293,3784,3786,3789,3791,3793,3795,3797,3799,3801,3803],{"class":75,"line":3785},42,[293,3787,3788],{"class":370},"            execution_options",[293,3790,351],{"class":298},[293,3792,800],{"class":302},[293,3794,803],{"class":360},[293,3796,806],{"class":302},[293,3798,640],{"class":376},[293,3800,637],{"class":302},[293,3802,3410],{"class":376},[293,3804,3805],{"class":302},"}},\n",[293,3807,3809],{"class":75,"line":3808},43,[293,3810,2063],{"class":302},[293,3812,3814,3816],{"class":75,"line":3813},44,[293,3815,3496],{"class":298},[293,3817,3818],{"class":302}," session.commit()\n",[293,3820,3822],{"class":75,"line":3821},45,[293,3823,342],{"emptyLinePlaceholder":341},[293,3825,3827],{"class":75,"line":3826},46,[293,3828,3829],{"class":609},"        # Read back from test schema\n",[293,3831,3833,3836,3838,3840],{"class":75,"line":3832},47,[293,3834,3835],{"class":302},"        result ",[293,3837,351],{"class":298},[293,3839,782],{"class":298},[293,3841,785],{"class":302},[293,3843,3845],{"class":75,"line":3844},48,[293,3846,3847],{"class":302},"            select(Invoice),\n",[293,3849,3851,3853,3855,3857,3859,3861,3863,3865,3867],{"class":75,"line":3850},49,[293,3852,3788],{"class":370},[293,3854,351],{"class":298},[293,3856,800],{"class":302},[293,3858,803],{"class":360},[293,3860,806],{"class":302},[293,3862,640],{"class":376},[293,3864,637],{"class":302},[293,3866,3410],{"class":376},[293,3868,3805],{"class":302},[293,3870,3872],{"class":75,"line":3871},50,[293,3873,2063],{"class":302},[293,3875,3877,3879,3881],{"class":75,"line":3876},51,[293,3878,1293],{"class":302},[293,3880,351],{"class":298},[293,3882,824],{"class":302},[293,3884,3886,3889,3892,3895,3897],{"class":75,"line":3885},52,[293,3887,3888],{"class":298},"        assert",[293,3890,3891],{"class":376}," len",[293,3893,3894],{"class":302},"(invoices) ",[293,3896,762],{"class":298},[293,3898,3899],{"class":376}," 1\n",[293,3901,3903,3905,3908,3910,3913,3915],{"class":75,"line":3902},53,[293,3904,3888],{"class":298},[293,3906,3907],{"class":302}," invoices[",[293,3909,47],{"class":376},[293,3911,3912],{"class":302},"].amount_cents ",[293,3914,762],{"class":298},[293,3916,3917],{"class":376}," 1000\n",[14,3919,3920,3921,3923],{},"This pattern validates that ",[267,3922,108],{}," correctly rewrites table names to the test schema, giving high confidence that production routing will behave identically across all tenant schemas.",[244,3925,3927],{"id":3926},"production-pitfalls-anti-patterns","Production Pitfalls & Anti-Patterns",[3929,3930,3931,3947,3968,3984,4002,4012],"ul",{},[2800,3932,3933,3938,3939,3942,3943,3946],{},[263,3934,3935,3936],{},"Passing raw tenant identifiers into ",[267,3937,108],{}," — Never do ",[267,3940,3941],{},"schema_translate_map={None: request.headers[\"X-Tenant\"]}",". An attacker can set the header to ",[267,3944,3945],{},"public; DROP TABLE users; --",". Always validate against an allowlist of known schema names before routing.",[2800,3948,3949,3955,3956,3959,3960,3963,3964,3967],{},[263,3950,3951,3952,3954],{},"Sharing an ",[267,3953,928],{}," across coroutines with different schemas"," — The identity map does not distinguish schema context, so ",[267,3957,3958],{},"session.get(Invoice, 5)"," on schema ",[267,3961,3962],{},"acme"," will return a cached instance originally loaded from schema ",[267,3965,3966],{},"globex"," if the same primary key exists in both. Use isolated sessions per request.",[2800,3969,3970,3976,3977,3980,3981,3983],{},[263,3971,3972,3973,3975],{},"Using ",[267,3974,171],{}," with PgBouncer in transaction pooling mode"," — ",[267,3978,3979],{},"SET search_path"," is a session-level variable. In transaction pooling mode, the underlying connection is returned to the pool after each transaction. The next client that checks it out inherits the previous search path unless you reset it. Use ",[267,3982,108],{}," instead, which operates entirely at the Python layer.",[2800,3985,3986,3993,3994,3997,3998,4001],{},[263,3987,3988,3989,3992],{},"Forgetting ",[267,3990,3991],{},"pool_pre_ping=True"," on per-tenant engines"," — When a tenant's engine goes idle for hours and the database server closes the TCP connection, the next query raises ",[267,3995,3996],{},"asyncpg.exceptions.ConnectionDoesNotExistError",". ",[267,3999,4000],{},"pool_pre_ping"," detects dead connections before use.",[2800,4003,4004,4007,4008,4011],{},[263,4005,4006],{},"Leaking async engines in the registry"," — If ",[267,4009,4010],{},"get_or_create_engine()"," is called with attacker-controlled tenant IDs, the registry grows unbounded. Cap the registry size and validate tenant IDs against the tenants table before creating an engine.",[2800,4013,4014,4020,4021,4023],{},[263,4015,4016,4017,4019],{},"Applying ",[267,4018,108],{}," at the engine level in a shared-schema deployment"," — An engine-level map affects every connection from that pool permanently, including health checks and background tasks. Apply the map at the ",[267,4022,913],{}," call level, never at the engine level in multi-tenant deployments.",[244,4025,4027],{"id":4026},"frequently-asked-questions","Frequently Asked Questions",[14,4029,4030,4039,4041,4042,4044,4045,4047,4048,4050],{},[263,4031,4032,4033,4035,4036,4038],{},"What is the difference between ",[267,4034,108],{}," and setting ",[267,4037,171],{},"?",[267,4040,108],{}," is a SQLAlchemy-level rewrite: it modifies the compiled SQL string before it is sent to the database, replacing schema references in table identifiers. ",[267,4043,171],{}," is a PostgreSQL session variable that instructs the server to resolve unqualified names by searching listed schemas in order. Both achieve schema routing, but ",[267,4046,108],{}," works at the Python layer and is connection-pool-safe, while ",[267,4049,171],{}," is a server-side connection state that can leak across pooled connections if not reset on checkout.",[14,4052,4053,4065,4066,4069,4070,4073,4074,4076,4077,1888],{},[263,4054,4055,4056,4058,4059,4061,4062,4038],{},"Can I use ",[267,4057,108],{}," with models that have an explicit ",[267,4060,1656],{}," set in ",[267,4063,4064],{},"__table_args__","\nYes. The map key must match the schema string declared in the model. If your model declares ",[267,4067,4068],{},"__table_args__ = {\"schema\": \"shared\"}",", then the map entry must be ",[267,4071,4072],{},"{\"shared\": \"acme_shared\"}",". The key ",[267,4075,640],{}," only matches models that declare no schema or ",[267,4078,4079],{},"schema=None",[14,4081,4082,4085,4086,4089,4090,4093,4094,4097,4098,4101],{},[263,4083,4084],{},"How do async engines handle connection limits when each tenant gets its own engine?","\nEach ",[267,4087,4088],{},"AsyncEngine"," maintains its own connection pool. With 100 tenants each having ",[267,4091,4092],{},"pool_size=5",", you could hold up to 500 open connections to the database server. PostgreSQL's default ",[267,4095,4096],{},"max_connections"," is 100. Use smaller per-tenant pools (",[267,4099,4100],{},"pool_size=2, max_overflow=1",") for inactive tenants, or implement a shared pool proxy like PgBouncer and give each tenant a distinct database URL with a connection limit enforced at the proxy level.",[14,4103,4104,4113,4114,4116,4117,1383,4120,1383,4123,4126,4127,4129,4130,4132,4133,1888],{},[263,4105,4106,4107,4109,4110,4112],{},"Does ",[267,4108,108],{}," work with Core ",[267,4111,2545],{}," statements?","\nNo. ",[267,4115,108],{}," only rewrites table references in compiled SQLAlchemy Core expressions (",[267,4118,4119],{},"Table",[267,4121,4122],{},"mapped_column",[267,4124,4125],{},"select(Model)","). Raw ",[267,4128,2545],{}," statements are passed through verbatim. If you must use ",[267,4131,2545],{}," with schema routing, embed the schema name directly via string formatting after validating it against an allowlist: ",[267,4134,4135],{},"text(f\"SELECT * FROM {validated_schema}.invoices\")",[14,4137,4138,4141,4142,4145,4146,4149,4150,4152,4153,4156,4157,4160],{},[263,4139,4140],{},"How do I run Alembic migrations across all tenant schemas?","\nBuild a migration script that iterates the list of tenant schema names from the ",[267,4143,4144],{},"tenants"," table, then calls ",[267,4147,4148],{},"alembic upgrade head"," with a modified ",[267,4151,1879],{}," that sets ",[267,4154,4155],{},"schema_translate_map={None: schema_name}"," for each run. Use a dedicated sync engine (not the async engine) because Alembic's migration environment is synchronous. The ",[18,4158,4159],{"href":1886},"Alembic async configuration guide"," shows how to wire this up.",[244,4162,4164],{"id":4163},"related","Related",[3929,4166,4167,4172,4178,4184,4191],{},[2800,4168,4169,4171],{},[18,4170,21],{"href":20}," — Parent guide covering the full spectrum of SQLAlchemy 2.0 query optimization.",[2800,4173,4174,4177],{},[18,4175,4176],{"href":3125},"Switching Schemas per Request with schema_translate_map"," — Step-by-step FastAPI dependency wiring for per-request schema routing.",[2800,4179,4180,4183],{},[18,4181,4182],{"href":2156},"Routing Reads to Replicas with Async Engines"," — Implementing write-to-primary read-from-replica splits with a routing session subclass.",[2800,4185,4186,4190],{},[18,4187,4189],{"href":4188},"\u002Fasync-engines-dialects-and-connection-pooling\u002F","Async Engines, Dialects and Connection Pooling"," — Connection pool sizing and async driver configuration that underpins all schema and replica routing.",[2800,4192,4193,4197],{},[18,4194,4196],{"href":4195},"\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fstreaming-large-result-sets-with-yield-per\u002F","Streaming Large Result Sets with yield_per"," — Efficiently processing large per-tenant datasets without memory exhaustion.",[4199,4200,4201],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":289,"searchDepth":312,"depth":312,"links":4203},[4204,4205,4206,4209,4216,4223,4224,4225],{"id":246,"depth":312,"text":247},{"id":834,"depth":312,"text":835},{"id":1301,"depth":312,"text":1302,"children":4207},[4208],{"id":1682,"depth":325,"text":1683},{"id":1791,"depth":312,"text":1792,"children":4210},[4211,4212,4213,4214,4215],{"id":1795,"depth":325,"text":1796},{"id":1891,"depth":325,"text":1892},{"id":2149,"depth":325,"text":2150},{"id":2290,"depth":325,"text":2291},{"id":2538,"depth":325,"text":2539},{"id":2780,"depth":312,"text":2781,"children":4217},[4218,4220,4221,4222],{"id":2784,"depth":325,"text":4219},"Migrating from SQLAlchemy 1.4 Session(binds=...)",{"id":2980,"depth":325,"text":2981},{"id":3129,"depth":325,"text":3130},{"id":3357,"depth":325,"text":3358},{"id":3926,"depth":312,"text":3927},{"id":4026,"depth":312,"text":4027},{"id":4163,"depth":312,"text":4164},"SQLAlchemy 2.0 provides first-class primitives for directing every database operation to the correct schema or engine at runtime, without restructuring your ORM models. Within the broader Advanced Query Patterns and Bulk Data Operations discipline, dynamic schema routing sits at the intersection of connection pool management, transaction boundary design, and security isolation — making it one of the highest-stakes architectural decisions in a multi-tenant Python backend.","md",{"date":4229},"2026-06-18","\u002Fadvanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing",{"title":5,"description":4226},"advanced-query-patterns-and-bulk-data-operations\u002Fdynamic-schema-and-multi-tenant-routing\u002Findex","akCRXQMSehGloDZgqycfPZwd8CwellX2RZbbm6T6HV4",1781810028979]