[{"data":1,"prerenderedAt":4254},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F":4233},{"id":4,"title":5,"body":6,"description":4223,"extension":4224,"meta":4225,"navigation":467,"path":4229,"seo":4230,"stem":4231,"__hash__":4232},"content\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Findex.md","Async Database Access with SQLAlchemy 2.0 and FastAPI",{"type":7,"value":8,"toc":4209},"minimark",[9,13,28,41,190,193,198,201,237,252,349,357,359,363,366,377,1096,1111,1120,1123,1218,1220,1224,1239,1254,1369,1375,1395,1397,1401,1412,1536,1550,1670,1677,1680,2002,2012,2014,2018,2025,2035,2427,2451,2641,2644,2729,2749,2751,2755,2766,2814,2821,3131,3149,3160,3294,3302,3395,3397,3401,3574,3582,3584,3588,3612,3633,3642,3662,3672,3674,3678,3681,3815,3848,3866,3873,4078,4086,4088,4092,4100,4102,4106,4112,4121,4134,4140,4150,4152,4156,4161,4192,4197,4205],[10,11,5],"h1",{"id":12},"async-database-access-with-sqlalchemy-20-and-fastapi",[14,15,16,17,22,23,27],"p",{},"Your API is only as fast as its slowest database round trip, and the database is usually the first line item on your infrastructure bill that scales with customers rather than with time. This guide shows you how to run async SQLAlchemy 2.0 against Postgres from an async-native ",[18,19,21],"a",{"href":20},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","FastAPI service"," the way a paying product needs it: one engine per process, a pool sized to your actual host, exactly one transaction per request, queries that do not silently issue two hundred round trips, and Alembic migrations that ship without a maintenance window. Part of the ",[18,24,26],{"href":25},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," guide.",[14,29,30,31,35,36,40],{},"Everything here assumes you have chosen a driver. If you have not, read ",[18,32,34],{"href":33},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002F","asyncpg vs psycopg3 for FastAPI"," first — the code below uses ",[37,38,39],"code",{},"asyncpg",", which is the faster of the two for read-heavy JSON APIs, but every pattern on this page works with either.",[42,43,51,52,51,56,51,60,51,79,51,86,51,94,51,104,51,110,51,115,51,118,51,122,51,125,51,132,51,135,51,139,51,143,51,146,51,148,51,150,51,152,51,157,51,160,51,164,51,169,51,173,51,177,51,186],"svg",{"viewBox":44,"role":45,"ariaLabelledBy":46,"xmlns":49,"style":50},"0 0 760 320","img",[47,48],"asqla-arch-t","asqla-arch-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[53,54,55],"title",{"id":47},"Where the connection pool lives",[57,58,59],"desc",{"id":48},"Each Uvicorn worker process owns one AsyncEngine with its own connection pool, and every pool holds a slice of the finite connection slots on the Postgres server.",[61,62,63,64,51],"defs",{},"\n    ",[65,66,73,74,63],"marker",{"id":67,"viewBox":68,"refX":69,"refY":70,"markerWidth":71,"markerHeight":71,"orient":72},"asqla-arch-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[75,76],"path",{"d":77,"fill":78},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[80,81],"rect",{"x":82,"y":82,"width":83,"height":84,"fill":85},"0","760","320","var(--c-surface)",[87,88,93],"text",{"x":89,"y":90,"fill":91,"style":92},"380","28","var(--c-text)","text-anchor:middle;font-size:14;font-family:var(--font-sans);","One engine per process, one pool per engine",[80,95],{"x":96,"y":97,"width":98,"height":99,"rx":100,"fill":101,"stroke":102,"style":103},"24","60","172","72","10","var(--c-surface-alt)","var(--c-blue)","stroke-width:2;",[87,105,109],{"x":106,"y":107,"fill":91,"style":108},"110","88","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Worker process 1",[87,111,114],{"x":106,"y":112,"fill":78,"style":113},"108","text-anchor:middle;font-size:12;font-family:var(--font-sans);","FastAPI event loop",[80,116],{"x":96,"y":117,"width":98,"height":99,"rx":100,"fill":101,"stroke":102,"style":103},"176",[87,119,121],{"x":106,"y":120,"fill":91,"style":108},"204","Worker process 2",[87,123,114],{"x":106,"y":124,"fill":78,"style":113},"224",[126,127],"line",{"x1":128,"y1":129,"x2":130,"y2":129,"stroke":78,"style":131},"196","96","256","stroke-width:2;marker-end:url(#asqla-arch-arrow);",[126,133],{"x1":128,"y1":134,"x2":130,"y2":134,"stroke":78,"style":131},"212",[80,136],{"x":130,"y":97,"width":137,"height":99,"rx":100,"fill":85,"stroke":138,"style":103},"188","var(--c-teal)",[87,140,142],{"x":141,"y":107,"fill":91,"style":108},"350","AsyncEngine pool",[87,144,145],{"x":141,"y":112,"fill":78,"style":113},"size 5 + overflow 5",[80,147],{"x":130,"y":117,"width":137,"height":99,"rx":100,"fill":85,"stroke":138,"style":103},[87,149,142],{"x":141,"y":120,"fill":91,"style":108},[87,151,145],{"x":141,"y":124,"fill":78,"style":113},[126,153],{"x1":154,"y1":129,"x2":155,"y2":156,"stroke":78,"style":131},"444","512","130",[126,158],{"x1":154,"y1":134,"x2":155,"y2":159,"stroke":78,"style":131},"178",[80,161],{"x":155,"y":162,"width":124,"height":129,"rx":100,"fill":101,"stroke":163,"style":103},"106","var(--c-coral)",[87,165,168],{"x":166,"y":167,"fill":91,"style":92},"624","140","PostgreSQL",[87,170,172],{"x":166,"y":171,"fill":78,"style":113},"164","max_connections = 100",[87,174,176],{"x":166,"y":175,"fill":78,"style":113},"184","shared by every replica",[80,178],{"x":179,"y":180,"width":181,"height":182,"rx":183,"fill":184,"stroke":185,"style":103},"150","270","460","30","8","none","var(--c-yellow)",[87,187,189],{"x":89,"y":188,"fill":91,"style":113},"290","8 processes x 10 max connections = 80 of 100 slots",[191,192],"hr",{},[194,195,197],"h2",{"id":196},"prerequisites","Prerequisites",[14,199,200],{},"You need Python 3.11 or newer, Postgres 14 or newer, and four packages. Pin them; SQLAlchemy 1.4 and 2.0 differ enough in query style that a floating version will break your code at the worst moment.",[202,203,208],"pre",{"className":204,"code":205,"language":206,"meta":207,"style":207},"language-bash shiki shiki-themes github-light github-dark","pip install \"sqlalchemy[asyncio]>=2.0.30\" \"asyncpg>=0.29\" \"alembic>=1.13\" \"fastapi>=0.115\" \"uvicorn[standard]>=0.30\"\n","bash","",[37,209,210],{"__ignoreMap":207},[211,212,214,218,222,225,228,231,234],"span",{"class":126,"line":213},1,[211,215,217],{"class":216},"sScJk","pip",[211,219,221],{"class":220},"sZZnC"," install",[211,223,224],{"class":220}," \"sqlalchemy[asyncio]>=2.0.30\"",[211,226,227],{"class":220}," \"asyncpg>=0.29\"",[211,229,230],{"class":220}," \"alembic>=1.13\"",[211,232,233],{"class":220}," \"fastapi>=0.115\"",[211,235,236],{"class":220}," \"uvicorn[standard]>=0.30\"\n",[14,238,239,240,243,244,247,248,251],{},"The connection URL must carry the ",[37,241,242],{},"postgresql+asyncpg"," scheme, not plain ",[37,245,246],{},"postgresql",". If you paste the URL your host hands you into an async engine unchanged, SQLAlchemy loads the sync psycopg driver and every request raises ",[37,249,250],{},"MissingGreenlet"," the first time it touches the database. Normalise the scheme in code rather than in the dashboard, so the same environment variable feeds Alembic, your app, and your test suite:",[202,253,255],{"className":204,"code":254,"language":206,"meta":207,"style":207},"export DATABASE_URL=\"postgresql+asyncpg:\u002F\u002Fapi:secret@localhost:5432\u002Fappdb\"\nexport DB_POOL_SIZE=\"5\"\nexport DB_MAX_OVERFLOW=\"5\"\nexport DB_POOL_RECYCLE=\"1800\"\nexport DB_STATEMENT_TIMEOUT_MS=\"5000\"\nexport DB_ECHO=\"0\"\nexport SERVERLESS=\"0\"\n",[37,256,257,273,286,298,311,324,337],{"__ignoreMap":207},[211,258,259,263,267,270],{"class":126,"line":213},[211,260,262],{"class":261},"szBVR","export",[211,264,266],{"class":265},"sVt8B"," DATABASE_URL",[211,268,269],{"class":261},"=",[211,271,272],{"class":220},"\"postgresql+asyncpg:\u002F\u002Fapi:secret@localhost:5432\u002Fappdb\"\n",[211,274,276,278,281,283],{"class":126,"line":275},2,[211,277,262],{"class":261},[211,279,280],{"class":265}," DB_POOL_SIZE",[211,282,269],{"class":261},[211,284,285],{"class":220},"\"5\"\n",[211,287,289,291,294,296],{"class":126,"line":288},3,[211,290,262],{"class":261},[211,292,293],{"class":265}," DB_MAX_OVERFLOW",[211,295,269],{"class":261},[211,297,285],{"class":220},[211,299,301,303,306,308],{"class":126,"line":300},4,[211,302,262],{"class":261},[211,304,305],{"class":265}," DB_POOL_RECYCLE",[211,307,269],{"class":261},[211,309,310],{"class":220},"\"1800\"\n",[211,312,314,316,319,321],{"class":126,"line":313},5,[211,315,262],{"class":261},[211,317,318],{"class":265}," DB_STATEMENT_TIMEOUT_MS",[211,320,269],{"class":261},[211,322,323],{"class":220},"\"5000\"\n",[211,325,327,329,332,334],{"class":126,"line":326},6,[211,328,262],{"class":261},[211,330,331],{"class":265}," DB_ECHO",[211,333,269],{"class":261},[211,335,336],{"class":220},"\"0\"\n",[211,338,340,342,345,347],{"class":126,"line":339},7,[211,341,262],{"class":261},[211,343,344],{"class":265}," SERVERLESS",[211,346,269],{"class":261},[211,348,336],{"class":220},[14,350,351,352,356],{},"One more assumption: your response models are Pydantic v2. Async SQLAlchemy punishes lazy attribute access during serialization, so the boundary between ORM object and response schema matters more than it does in a sync app. If your models are loose, tighten them with ",[18,353,355],{"href":354},"\u002Fgetting-started-with-python-apis-for-builders\u002Fparsing-json-responses\u002Fvalidating-json-with-pydantic-v2\u002F","Pydantic v2 validation"," before you go further.",[191,358],{},[194,360,362],{"id":361},"step-1-create-exactly-one-engine-at-process-start","Step 1: Create exactly one engine, at process start",[14,364,365],{},"The engine is a heavyweight object that owns a connection pool. Create it once at module import or in the lifespan handler, never inside a request. A fresh engine per request opens a fresh TCP connection and a fresh TLS handshake every time, which typically adds 20–40 ms to every call and exhausts the server's connection slots under any real concurrency.",[14,367,368,369,372,373,376],{},"Keep the engine, the sessionmaker, and the declarative base in one small module. The ",[37,370,371],{},"AsyncAttrs"," mixin on the base is not optional in my opinion — it gives you ",[37,374,375],{},"await obj.awaitable_attrs.\u003Crelationship>"," as an escape hatch when you genuinely need a lazy load, instead of a runtime crash.",[202,378,382],{"className":379,"code":380,"language":381,"meta":207,"style":207},"language-python shiki shiki-themes github-light github-dark","import os\nfrom sqlalchemy.ext.asyncio import (\n    AsyncAttrs,\n    AsyncEngine,\n    AsyncSession,\n    async_sessionmaker,\n    create_async_engine,\n)\nfrom sqlalchemy.orm import DeclarativeBase\nfrom sqlalchemy.pool import NullPool\n\n\nclass Base(AsyncAttrs, DeclarativeBase):\n    \"\"\"Declarative base with awaitable attribute access.\"\"\"\n\n\ndef _url() -> str:\n    raw = os.environ[\"DATABASE_URL\"]\n    # Hosts hand out sync URLs; force the async driver.\n    if raw.startswith(\"postgresql:\u002F\u002F\"):\n        raw = raw.replace(\"postgresql:\u002F\u002F\", \"postgresql+asyncpg:\u002F\u002F\", 1)\n    return raw\n\n\ndef build_engine() -> AsyncEngine:\n    serverless = os.getenv(\"SERVERLESS\", \"0\") == \"1\"\n    timeout_ms = os.getenv(\"DB_STATEMENT_TIMEOUT_MS\", \"5000\")\n    connect_args = {\n        \"server_settings\": {\n            \"application_name\": os.getenv(\"APP_NAME\", \"api\"),\n            \"statement_timeout\": timeout_ms,\n        },\n        \"timeout\": float(os.getenv(\"DB_CONNECT_TIMEOUT\", \"10\")),\n    }\n\n    match serverless:\n        case True:\n            return create_async_engine(\n                _url(),\n                poolclass=NullPool,\n                connect_args={**connect_args, \"statement_cache_size\": 0},\n                echo=os.getenv(\"DB_ECHO\", \"0\") == \"1\",\n            )\n        case False:\n            return create_async_engine(\n                _url(),\n                pool_size=int(os.getenv(\"DB_POOL_SIZE\", \"5\")),\n                max_overflow=int(os.getenv(\"DB_MAX_OVERFLOW\", \"5\")),\n                pool_timeout=float(os.getenv(\"DB_POOL_TIMEOUT\", \"10\")),\n                pool_recycle=int(os.getenv(\"DB_POOL_RECYCLE\", \"1800\")),\n                pool_pre_ping=True,\n                echo=os.getenv(\"DB_ECHO\", \"0\") == \"1\",\n            )\n\n\nengine = build_engine()\n\nSessionLocal = async_sessionmaker(\n    engine,\n    expire_on_commit=False,\n    autoflush=False,\n)\n","python",[37,383,384,392,405,410,415,420,425,430,436,449,462,469,474,497,503,508,513,532,549,556,570,595,604,609,614,625,653,673,684,693,713,722,728,754,760,765,774,785,794,800,812,839,867,873,883,890,895,918,939,960,982,995,1018,1023,1028,1033,1044,1049,1060,1066,1079,1091],{"__ignoreMap":207},[211,385,386,389],{"class":126,"line":213},[211,387,388],{"class":261},"import",[211,390,391],{"class":265}," os\n",[211,393,394,397,400,402],{"class":126,"line":275},[211,395,396],{"class":261},"from",[211,398,399],{"class":265}," sqlalchemy.ext.asyncio ",[211,401,388],{"class":261},[211,403,404],{"class":265}," (\n",[211,406,407],{"class":126,"line":288},[211,408,409],{"class":265},"    AsyncAttrs,\n",[211,411,412],{"class":126,"line":300},[211,413,414],{"class":265},"    AsyncEngine,\n",[211,416,417],{"class":126,"line":313},[211,418,419],{"class":265},"    AsyncSession,\n",[211,421,422],{"class":126,"line":326},[211,423,424],{"class":265},"    async_sessionmaker,\n",[211,426,427],{"class":126,"line":339},[211,428,429],{"class":265},"    create_async_engine,\n",[211,431,433],{"class":126,"line":432},8,[211,434,435],{"class":265},")\n",[211,437,439,441,444,446],{"class":126,"line":438},9,[211,440,396],{"class":261},[211,442,443],{"class":265}," sqlalchemy.orm ",[211,445,388],{"class":261},[211,447,448],{"class":265}," DeclarativeBase\n",[211,450,452,454,457,459],{"class":126,"line":451},10,[211,453,396],{"class":261},[211,455,456],{"class":265}," sqlalchemy.pool ",[211,458,388],{"class":261},[211,460,461],{"class":265}," NullPool\n",[211,463,465],{"class":126,"line":464},11,[211,466,468],{"emptyLinePlaceholder":467},true,"\n",[211,470,472],{"class":126,"line":471},12,[211,473,468],{"emptyLinePlaceholder":467},[211,475,477,480,483,486,488,491,494],{"class":126,"line":476},13,[211,478,479],{"class":261},"class",[211,481,482],{"class":216}," Base",[211,484,485],{"class":265},"(",[211,487,371],{"class":216},[211,489,490],{"class":265},", ",[211,492,493],{"class":216},"DeclarativeBase",[211,495,496],{"class":265},"):\n",[211,498,500],{"class":126,"line":499},14,[211,501,502],{"class":220},"    \"\"\"Declarative base with awaitable attribute access.\"\"\"\n",[211,504,506],{"class":126,"line":505},15,[211,507,468],{"emptyLinePlaceholder":467},[211,509,511],{"class":126,"line":510},16,[211,512,468],{"emptyLinePlaceholder":467},[211,514,516,519,522,525,529],{"class":126,"line":515},17,[211,517,518],{"class":261},"def",[211,520,521],{"class":216}," _url",[211,523,524],{"class":265},"() -> ",[211,526,528],{"class":527},"sj4cs","str",[211,530,531],{"class":265},":\n",[211,533,535,538,540,543,546],{"class":126,"line":534},18,[211,536,537],{"class":265},"    raw ",[211,539,269],{"class":261},[211,541,542],{"class":265}," os.environ[",[211,544,545],{"class":220},"\"DATABASE_URL\"",[211,547,548],{"class":265},"]\n",[211,550,552],{"class":126,"line":551},19,[211,553,555],{"class":554},"sJ8bj","    # Hosts hand out sync URLs; force the async driver.\n",[211,557,559,562,565,568],{"class":126,"line":558},20,[211,560,561],{"class":261},"    if",[211,563,564],{"class":265}," raw.startswith(",[211,566,567],{"class":220},"\"postgresql:\u002F\u002F\"",[211,569,496],{"class":265},[211,571,573,576,578,581,583,585,588,590,593],{"class":126,"line":572},21,[211,574,575],{"class":265},"        raw ",[211,577,269],{"class":261},[211,579,580],{"class":265}," raw.replace(",[211,582,567],{"class":220},[211,584,490],{"class":265},[211,586,587],{"class":220},"\"postgresql+asyncpg:\u002F\u002F\"",[211,589,490],{"class":265},[211,591,592],{"class":527},"1",[211,594,435],{"class":265},[211,596,598,601],{"class":126,"line":597},22,[211,599,600],{"class":261},"    return",[211,602,603],{"class":265}," raw\n",[211,605,607],{"class":126,"line":606},23,[211,608,468],{"emptyLinePlaceholder":467},[211,610,612],{"class":126,"line":611},24,[211,613,468],{"emptyLinePlaceholder":467},[211,615,617,619,622],{"class":126,"line":616},25,[211,618,518],{"class":261},[211,620,621],{"class":216}," build_engine",[211,623,624],{"class":265},"() -> AsyncEngine:\n",[211,626,628,631,633,636,639,641,644,647,650],{"class":126,"line":627},26,[211,629,630],{"class":265},"    serverless ",[211,632,269],{"class":261},[211,634,635],{"class":265}," os.getenv(",[211,637,638],{"class":220},"\"SERVERLESS\"",[211,640,490],{"class":265},[211,642,643],{"class":220},"\"0\"",[211,645,646],{"class":265},") ",[211,648,649],{"class":261},"==",[211,651,652],{"class":220}," \"1\"\n",[211,654,656,659,661,663,666,668,671],{"class":126,"line":655},27,[211,657,658],{"class":265},"    timeout_ms ",[211,660,269],{"class":261},[211,662,635],{"class":265},[211,664,665],{"class":220},"\"DB_STATEMENT_TIMEOUT_MS\"",[211,667,490],{"class":265},[211,669,670],{"class":220},"\"5000\"",[211,672,435],{"class":265},[211,674,676,679,681],{"class":126,"line":675},28,[211,677,678],{"class":265},"    connect_args ",[211,680,269],{"class":261},[211,682,683],{"class":265}," {\n",[211,685,687,690],{"class":126,"line":686},29,[211,688,689],{"class":220},"        \"server_settings\"",[211,691,692],{"class":265},": {\n",[211,694,696,699,702,705,707,710],{"class":126,"line":695},30,[211,697,698],{"class":220},"            \"application_name\"",[211,700,701],{"class":265},": os.getenv(",[211,703,704],{"class":220},"\"APP_NAME\"",[211,706,490],{"class":265},[211,708,709],{"class":220},"\"api\"",[211,711,712],{"class":265},"),\n",[211,714,716,719],{"class":126,"line":715},31,[211,717,718],{"class":220},"            \"statement_timeout\"",[211,720,721],{"class":265},": timeout_ms,\n",[211,723,725],{"class":126,"line":724},32,[211,726,727],{"class":265},"        },\n",[211,729,731,734,737,740,743,746,748,751],{"class":126,"line":730},33,[211,732,733],{"class":220},"        \"timeout\"",[211,735,736],{"class":265},": ",[211,738,739],{"class":527},"float",[211,741,742],{"class":265},"(os.getenv(",[211,744,745],{"class":220},"\"DB_CONNECT_TIMEOUT\"",[211,747,490],{"class":265},[211,749,750],{"class":220},"\"10\"",[211,752,753],{"class":265},")),\n",[211,755,757],{"class":126,"line":756},34,[211,758,759],{"class":265},"    }\n",[211,761,763],{"class":126,"line":762},35,[211,764,468],{"emptyLinePlaceholder":467},[211,766,768,771],{"class":126,"line":767},36,[211,769,770],{"class":261},"    match",[211,772,773],{"class":265}," serverless:\n",[211,775,777,780,783],{"class":126,"line":776},37,[211,778,779],{"class":261},"        case",[211,781,782],{"class":527}," True",[211,784,531],{"class":265},[211,786,788,791],{"class":126,"line":787},38,[211,789,790],{"class":261},"            return",[211,792,793],{"class":265}," create_async_engine(\n",[211,795,797],{"class":126,"line":796},39,[211,798,799],{"class":265},"                _url(),\n",[211,801,803,807,809],{"class":126,"line":802},40,[211,804,806],{"class":805},"s4XuR","                poolclass",[211,808,269],{"class":261},[211,810,811],{"class":265},"NullPool,\n",[211,813,815,818,820,823,826,829,832,834,836],{"class":126,"line":814},41,[211,816,817],{"class":805},"                connect_args",[211,819,269],{"class":261},[211,821,822],{"class":265},"{",[211,824,825],{"class":261},"**",[211,827,828],{"class":265},"connect_args, ",[211,830,831],{"class":220},"\"statement_cache_size\"",[211,833,736],{"class":265},[211,835,82],{"class":527},[211,837,838],{"class":265},"},\n",[211,840,842,845,847,850,853,855,857,859,861,864],{"class":126,"line":841},42,[211,843,844],{"class":805},"                echo",[211,846,269],{"class":261},[211,848,849],{"class":265},"os.getenv(",[211,851,852],{"class":220},"\"DB_ECHO\"",[211,854,490],{"class":265},[211,856,643],{"class":220},[211,858,646],{"class":265},[211,860,649],{"class":261},[211,862,863],{"class":220}," \"1\"",[211,865,866],{"class":265},",\n",[211,868,870],{"class":126,"line":869},43,[211,871,872],{"class":265},"            )\n",[211,874,876,878,881],{"class":126,"line":875},44,[211,877,779],{"class":261},[211,879,880],{"class":527}," False",[211,882,531],{"class":265},[211,884,886,888],{"class":126,"line":885},45,[211,887,790],{"class":261},[211,889,793],{"class":265},[211,891,893],{"class":126,"line":892},46,[211,894,799],{"class":265},[211,896,898,901,903,906,908,911,913,916],{"class":126,"line":897},47,[211,899,900],{"class":805},"                pool_size",[211,902,269],{"class":261},[211,904,905],{"class":527},"int",[211,907,742],{"class":265},[211,909,910],{"class":220},"\"DB_POOL_SIZE\"",[211,912,490],{"class":265},[211,914,915],{"class":220},"\"5\"",[211,917,753],{"class":265},[211,919,921,924,926,928,930,933,935,937],{"class":126,"line":920},48,[211,922,923],{"class":805},"                max_overflow",[211,925,269],{"class":261},[211,927,905],{"class":527},[211,929,742],{"class":265},[211,931,932],{"class":220},"\"DB_MAX_OVERFLOW\"",[211,934,490],{"class":265},[211,936,915],{"class":220},[211,938,753],{"class":265},[211,940,942,945,947,949,951,954,956,958],{"class":126,"line":941},49,[211,943,944],{"class":805},"                pool_timeout",[211,946,269],{"class":261},[211,948,739],{"class":527},[211,950,742],{"class":265},[211,952,953],{"class":220},"\"DB_POOL_TIMEOUT\"",[211,955,490],{"class":265},[211,957,750],{"class":220},[211,959,753],{"class":265},[211,961,963,966,968,970,972,975,977,980],{"class":126,"line":962},50,[211,964,965],{"class":805},"                pool_recycle",[211,967,269],{"class":261},[211,969,905],{"class":527},[211,971,742],{"class":265},[211,973,974],{"class":220},"\"DB_POOL_RECYCLE\"",[211,976,490],{"class":265},[211,978,979],{"class":220},"\"1800\"",[211,981,753],{"class":265},[211,983,985,988,990,993],{"class":126,"line":984},51,[211,986,987],{"class":805},"                pool_pre_ping",[211,989,269],{"class":261},[211,991,992],{"class":527},"True",[211,994,866],{"class":265},[211,996,998,1000,1002,1004,1006,1008,1010,1012,1014,1016],{"class":126,"line":997},52,[211,999,844],{"class":805},[211,1001,269],{"class":261},[211,1003,849],{"class":265},[211,1005,852],{"class":220},[211,1007,490],{"class":265},[211,1009,643],{"class":220},[211,1011,646],{"class":265},[211,1013,649],{"class":261},[211,1015,863],{"class":220},[211,1017,866],{"class":265},[211,1019,1021],{"class":126,"line":1020},53,[211,1022,872],{"class":265},[211,1024,1026],{"class":126,"line":1025},54,[211,1027,468],{"emptyLinePlaceholder":467},[211,1029,1031],{"class":126,"line":1030},55,[211,1032,468],{"emptyLinePlaceholder":467},[211,1034,1036,1039,1041],{"class":126,"line":1035},56,[211,1037,1038],{"class":265},"engine ",[211,1040,269],{"class":261},[211,1042,1043],{"class":265}," build_engine()\n",[211,1045,1047],{"class":126,"line":1046},57,[211,1048,468],{"emptyLinePlaceholder":467},[211,1050,1052,1055,1057],{"class":126,"line":1051},58,[211,1053,1054],{"class":265},"SessionLocal ",[211,1056,269],{"class":261},[211,1058,1059],{"class":265}," async_sessionmaker(\n",[211,1061,1063],{"class":126,"line":1062},59,[211,1064,1065],{"class":265},"    engine,\n",[211,1067,1069,1072,1074,1077],{"class":126,"line":1068},60,[211,1070,1071],{"class":805},"    expire_on_commit",[211,1073,269],{"class":261},[211,1075,1076],{"class":527},"False",[211,1078,866],{"class":265},[211,1080,1082,1085,1087,1089],{"class":126,"line":1081},61,[211,1083,1084],{"class":805},"    autoflush",[211,1086,269],{"class":261},[211,1088,1076],{"class":527},[211,1090,866],{"class":265},[211,1092,1094],{"class":126,"line":1093},62,[211,1095,435],{"class":265},[14,1097,1098,1099,1102,1103,1106,1107,1110],{},"Two settings there earn their place. ",[37,1100,1101],{},"pool_pre_ping=True"," costs a trivial round trip when a connection is checked out and saves you from the single most common production error in this stack: a managed Postgres or a load balancer silently closing idle connections after five or ten minutes, leaving stale sockets in your pool that fail the next request with ",[37,1104,1105],{},"ConnectionDoesNotExistError",". ",[37,1108,1109],{},"pool_recycle=1800"," handles the same problem proactively by discarding connections older than half an hour.",[14,1112,1113,1116,1117,1119],{},[37,1114,1115],{},"expire_on_commit=False"," matters even more. By default SQLAlchemy expires every loaded attribute after a commit, so the next attribute access triggers a refresh query. In an async app that refresh happens outside a greenlet context during response serialization and raises ",[37,1118,250],{}," instead of quietly reloading. Turn it off and treat objects returned from a committed transaction as immutable snapshots.",[14,1121,1122],{},"Dispose the engine on shutdown so in-flight connections close cleanly rather than being reaped by Postgres later:",[202,1124,1126],{"className":379,"code":1125,"language":381,"meta":207,"style":207},"from contextlib import asynccontextmanager\nfrom fastapi import FastAPI\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    yield\n    await engine.dispose()\n\n\napp = FastAPI(lifespan=lifespan)\n",[37,1127,1128,1140,1152,1156,1160,1165,1179,1184,1192,1196,1200],{"__ignoreMap":207},[211,1129,1130,1132,1135,1137],{"class":126,"line":213},[211,1131,396],{"class":261},[211,1133,1134],{"class":265}," contextlib ",[211,1136,388],{"class":261},[211,1138,1139],{"class":265}," asynccontextmanager\n",[211,1141,1142,1144,1147,1149],{"class":126,"line":275},[211,1143,396],{"class":261},[211,1145,1146],{"class":265}," fastapi ",[211,1148,388],{"class":261},[211,1150,1151],{"class":265}," FastAPI\n",[211,1153,1154],{"class":126,"line":288},[211,1155,468],{"emptyLinePlaceholder":467},[211,1157,1158],{"class":126,"line":300},[211,1159,468],{"emptyLinePlaceholder":467},[211,1161,1162],{"class":126,"line":313},[211,1163,1164],{"class":216},"@asynccontextmanager\n",[211,1166,1167,1170,1173,1176],{"class":126,"line":326},[211,1168,1169],{"class":261},"async",[211,1171,1172],{"class":261}," def",[211,1174,1175],{"class":216}," lifespan",[211,1177,1178],{"class":265},"(app: FastAPI):\n",[211,1180,1181],{"class":126,"line":339},[211,1182,1183],{"class":261},"    yield\n",[211,1185,1186,1189],{"class":126,"line":432},[211,1187,1188],{"class":261},"    await",[211,1190,1191],{"class":265}," engine.dispose()\n",[211,1193,1194],{"class":126,"line":438},[211,1195,468],{"emptyLinePlaceholder":467},[211,1197,1198],{"class":126,"line":451},[211,1199,468],{"emptyLinePlaceholder":467},[211,1201,1202,1205,1207,1210,1213,1215],{"class":126,"line":464},[211,1203,1204],{"class":265},"app ",[211,1206,269],{"class":261},[211,1208,1209],{"class":265}," FastAPI(",[211,1211,1212],{"class":805},"lifespan",[211,1214,269],{"class":261},[211,1216,1217],{"class":265},"lifespan)\n",[191,1219],{},[194,1221,1223],{"id":1222},"step-2-size-the-pool-for-the-host-you-actually-run-on","Step 2: Size the pool for the host you actually run on",[14,1225,1226,1227,1230,1231,1234,1235,1238],{},"Pool sizing is arithmetic, not taste. Your total connection demand is ",[37,1228,1229],{},"replicas × worker processes × (pool_size + max_overflow)",". Compare that to your Postgres ",[37,1232,1233],{},"max_connections"," minus the slots reserved for superusers, your migration job, your analytics tool, and any background worker fleet. If the total exceeds the ceiling, you do not get slow requests — you get ",[37,1236,1237],{},"FATAL: sorry, too many clients already",", which surfaces as 500s across every endpoint simultaneously.",[14,1240,1241,1242,1246,1247,490,1250,1253],{},"Work backwards. A typical managed Postgres starter plan gives you 100 connections. Reserve 20 for humans, migrations, and ",[18,1243,1245],{"href":1244},"\u002Fscaling-and-operating-production-python-apis\u002Frunning-background-jobs-with-celery\u002F","Celery workers",", leaving 80 for the API. Two replicas of four Uvicorn workers is eight processes, so each process gets ten: ",[37,1248,1249],{},"pool_size=5",[37,1251,1252],{},"max_overflow=5",". Steady-state traffic sits inside the five pooled connections and bursts borrow up to five more, which are closed again when they go idle.",[42,1255,51,1260,51,1263,51,1266,51,1269,51,1275,51,1279,51,1283,51,1287,51,1291,51,1294,51,1298,51,1301,51,1304,51,1308,51,1312,51,1315,51,1318,51,1321,51,1325,51,1327,51,1329,51,1332,51,1336,51,1339,51,1341,51,1343,51,1347,51,1350,51,1353,51,1356,51,1360,51,1363,51,1366],{"viewBox":1256,"role":45,"ariaLabelledBy":1257,"xmlns":49,"style":50},"0 0 760 360",[1258,1259],"asqla-pool-t","asqla-pool-d",[53,1261,1262],{"id":1258},"Pool settings for long-lived hosts versus serverless",[57,1264,1265],{"id":1259},"A comparison table contrasting QueuePool settings on a long-lived container host with NullPool settings on serverless runtimes, across pool class, size, overflow, recycle, and whether an external pooler is required.",[80,1267],{"x":82,"y":82,"width":83,"height":1268,"fill":85},"360",[80,1270],{"x":96,"y":96,"width":1271,"height":1272,"rx":183,"fill":101,"stroke":1273,"style":1274},"712","40","var(--c-border)","stroke-width:1;",[87,1276,1278],{"x":167,"y":1277,"fill":91,"style":108},"49","Setting",[87,1280,1282],{"x":1281,"y":1277,"fill":91,"style":108},"392","Long-lived container",[87,1284,1286],{"x":1285,"y":1277,"fill":91,"style":108},"628","Serverless function",[126,1288],{"x1":1289,"y1":96,"x2":1289,"y2":1290,"stroke":1273,"style":1274},"264","336",[126,1292],{"x1":1293,"y1":96,"x2":1293,"y2":1290,"stroke":1273,"style":1274},"520",[87,1295,1297],{"x":1272,"y":129,"fill":78,"style":1296},"font-size:12;font-family:var(--font-sans);","Pool class",[87,1299,1300],{"x":1281,"y":129,"fill":138,"style":113},"QueuePool (default)",[87,1302,1303],{"x":1285,"y":129,"fill":163,"style":113},"NullPool",[126,1305],{"x1":96,"y1":1306,"x2":1307,"y2":1306,"stroke":1273,"style":1274},"112","736",[87,1309,1311],{"x":1272,"y":1310,"fill":78,"style":1296},"144","pool_size",[87,1313,1314],{"x":1281,"y":1310,"fill":91,"style":113},"5 per process",[87,1316,1317],{"x":1285,"y":1310,"fill":91,"style":113},"not applicable",[126,1319],{"x1":96,"y1":1320,"x2":1307,"y2":1320,"stroke":1273,"style":1274},"160",[87,1322,1324],{"x":1272,"y":1323,"fill":78,"style":1296},"192","max_overflow",[87,1326,1314],{"x":1281,"y":1323,"fill":91,"style":113},[87,1328,1317],{"x":1285,"y":1323,"fill":91,"style":113},[126,1330],{"x1":96,"y1":1331,"x2":1307,"y2":1331,"stroke":1273,"style":1274},"208",[87,1333,1335],{"x":1272,"y":1334,"fill":78,"style":1296},"240","pool_recycle",[87,1337,1338],{"x":1281,"y":1334,"fill":91,"style":113},"1800 seconds",[87,1340,1317],{"x":1285,"y":1334,"fill":91,"style":113},[126,1342],{"x1":96,"y1":130,"x2":1307,"y2":130,"stroke":1273,"style":1274},[87,1344,1346],{"x":1272,"y":1345,"fill":78,"style":1296},"288","Statement cache",[87,1348,1349],{"x":1281,"y":1345,"fill":91,"style":113},"on (default)",[87,1351,1352],{"x":1285,"y":1345,"fill":91,"style":113},"off, size 0",[126,1354],{"x1":96,"y1":1355,"x2":1307,"y2":1355,"stroke":1273,"style":1274},"304",[87,1357,1359],{"x":1272,"y":1358,"fill":78,"style":1296},"330","External pooler",[87,1361,1362],{"x":1281,"y":1358,"fill":91,"style":113},"optional",[87,1364,1365],{"x":1285,"y":1358,"fill":91,"style":113},"mandatory",[80,1367],{"x":96,"y":96,"width":1271,"height":1368,"rx":183,"fill":184,"stroke":1273,"style":1274},"312",[14,1370,1371,1372,1374],{},"Serverless inverts the logic. On a platform that freezes or destroys the process between invocations — Vercel functions, Lambda, Cloud Run scaled to zero — an in-process pool is worthless and actively harmful. Each cold instance opens its own pooled connections, then gets frozen while still holding them. Fifty concurrent instances with a five-connection pool means 250 half-dead connections against a 100-slot server. Use ",[37,1373,1303],{}," so every request opens and closes a single connection, and put a real pooler in front: PgBouncer in transaction mode, Supabase's pooler port, or Neon's pooled endpoint. The pooler multiplexes thousands of short client connections onto a few dozen server connections, which is exactly the job an in-process pool cannot do across process boundaries.",[14,1376,1377,1378,1381,1382,1385,1386,1389,1390,1394],{},"One caveat when you go through PgBouncer in transaction mode: asyncpg's prepared statement cache breaks, because a prepared statement created on one server connection is not visible on the next. Set ",[37,1379,1380],{},"statement_cache_size=0"," in ",[37,1383,1384],{},"connect_args",", as the serverless branch above does. Skip that and you get sporadic ",[37,1387,1388],{},"InvalidSQLStatementNameError"," under load — a bug that never reproduces on your laptop. The full diagnosis lives in ",[18,1391,1393],{"href":1392},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","fixing connection pool exhaustion",".",[191,1396],{},[194,1398,1400],{"id":1399},"step-3-scope-one-transaction-per-request","Step 3: Scope one transaction per request",[14,1402,1403,1404,1407,1408,1411],{},"The cleanest transaction boundary in a web API is the request itself. Open a session, begin a transaction, run the handler, commit on success, roll back on any exception. A FastAPI dependency with ",[37,1405,1406],{},"yield"," expresses this in six lines, and it means no handler ever calls ",[37,1409,1410],{},"commit()"," by hand — which in turn means no half-written record survives a mid-handler exception.",[202,1413,1415],{"className":379,"code":1414,"language":381,"meta":207,"style":207},"from collections.abc import AsyncIterator\nfrom typing import Annotated\nfrom fastapi import Depends\nfrom sqlalchemy.ext.asyncio import AsyncSession\n\n\nasync def get_session() -> AsyncIterator[AsyncSession]:\n    async with SessionLocal() as session:\n        async with session.begin():\n            yield session\n\n\nDbSession = Annotated[AsyncSession, Depends(get_session)]\n",[37,1416,1417,1429,1441,1452,1463,1467,1471,1483,1500,1510,1518,1522,1526],{"__ignoreMap":207},[211,1418,1419,1421,1424,1426],{"class":126,"line":213},[211,1420,396],{"class":261},[211,1422,1423],{"class":265}," collections.abc ",[211,1425,388],{"class":261},[211,1427,1428],{"class":265}," AsyncIterator\n",[211,1430,1431,1433,1436,1438],{"class":126,"line":275},[211,1432,396],{"class":261},[211,1434,1435],{"class":265}," typing ",[211,1437,388],{"class":261},[211,1439,1440],{"class":265}," Annotated\n",[211,1442,1443,1445,1447,1449],{"class":126,"line":288},[211,1444,396],{"class":261},[211,1446,1146],{"class":265},[211,1448,388],{"class":261},[211,1450,1451],{"class":265}," Depends\n",[211,1453,1454,1456,1458,1460],{"class":126,"line":300},[211,1455,396],{"class":261},[211,1457,399],{"class":265},[211,1459,388],{"class":261},[211,1461,1462],{"class":265}," AsyncSession\n",[211,1464,1465],{"class":126,"line":313},[211,1466,468],{"emptyLinePlaceholder":467},[211,1468,1469],{"class":126,"line":326},[211,1470,468],{"emptyLinePlaceholder":467},[211,1472,1473,1475,1477,1480],{"class":126,"line":339},[211,1474,1169],{"class":261},[211,1476,1172],{"class":261},[211,1478,1479],{"class":216}," get_session",[211,1481,1482],{"class":265},"() -> AsyncIterator[AsyncSession]:\n",[211,1484,1485,1488,1491,1494,1497],{"class":126,"line":432},[211,1486,1487],{"class":261},"    async",[211,1489,1490],{"class":261}," with",[211,1492,1493],{"class":265}," SessionLocal() ",[211,1495,1496],{"class":261},"as",[211,1498,1499],{"class":265}," session:\n",[211,1501,1502,1505,1507],{"class":126,"line":438},[211,1503,1504],{"class":261},"        async",[211,1506,1490],{"class":261},[211,1508,1509],{"class":265}," session.begin():\n",[211,1511,1512,1515],{"class":126,"line":451},[211,1513,1514],{"class":261},"            yield",[211,1516,1517],{"class":265}," session\n",[211,1519,1520],{"class":126,"line":464},[211,1521,468],{"emptyLinePlaceholder":467},[211,1523,1524],{"class":126,"line":471},[211,1525,468],{"emptyLinePlaceholder":467},[211,1527,1528,1531,1533],{"class":126,"line":476},[211,1529,1530],{"class":265},"DbSession ",[211,1532,269],{"class":261},[211,1534,1535],{"class":265}," Annotated[AsyncSession, Depends(get_session)]\n",[14,1537,1538,1541,1542,1545,1546,1549],{},[37,1539,1540],{},"session.begin()"," commits when the block exits normally and rolls back when an exception escapes, including ",[37,1543,1544],{},"HTTPException",". That last detail is the point: raise ",[37,1547,1548],{},"HTTPException(status_code=409)"," halfway through a multi-table write and the whole thing unwinds. You get atomic endpoints for free.",[42,1551,51,1556,51,1559,51,1562,51,1569,51,1572,51,1576,51,1581,51,1584,51,1588,51,1591,51,1595,51,1599,51,1603,51,1607,51,1609,51,1611,51,1613,51,1618,51,1622,51,1626,51,1631,51,1635,51,1639,51,1644,51,1647,51,1650,51,1652,51,1656,51,1660,51,1664,51,1667],{"viewBox":1552,"role":45,"ariaLabelledBy":1553,"xmlns":49,"style":50},"0 0 760 340",[1554,1555],"asqla-txn-t","asqla-txn-d",[53,1557,1558],{"id":1554},"Transaction lifetime across one request",[57,1560,1561],{"id":1555},"A sequence diagram showing the dependency checking out a connection and issuing BEGIN, the handler running its queries, and COMMIT plus connection return happening after the handler finishes but before serialization completes.",[61,1563,63,1564,51],{},[65,1565,73,1567,63],{"id":1566,"viewBox":68,"refX":69,"refY":70,"markerWidth":71,"markerHeight":71,"orient":72},"asqla-txn-arrow",[75,1568],{"d":77,"fill":78},[80,1570],{"x":82,"y":82,"width":83,"height":1571,"fill":85},"340",[80,1573],{"x":182,"y":1574,"width":156,"height":1575,"rx":183,"fill":101,"stroke":102,"style":103},"20","34",[87,1577,1580],{"x":1578,"y":1579,"fill":91,"style":113},"95","42","HTTP request",[80,1582],{"x":1583,"y":1574,"width":156,"height":1575,"rx":183,"fill":101,"stroke":102,"style":103},"250",[87,1585,1587],{"x":1586,"y":1579,"fill":91,"style":113},"315","Dependency",[80,1589],{"x":1590,"y":1574,"width":156,"height":1575,"rx":183,"fill":101,"stroke":138,"style":103},"450",[87,1592,1594],{"x":1593,"y":1579,"fill":91,"style":113},"515","AsyncSession",[80,1596],{"x":1597,"y":1574,"width":1598,"height":1575,"rx":183,"fill":101,"stroke":163,"style":103},"620","118",[87,1600,1602],{"x":1601,"y":1579,"fill":91,"style":113},"679","Postgres",[126,1604],{"x1":1578,"y1":1605,"x2":1578,"y2":1606,"stroke":1273,"style":1274},"54","310",[126,1608],{"x1":1586,"y1":1605,"x2":1586,"y2":1606,"stroke":1273,"style":1274},[126,1610],{"x1":1593,"y1":1605,"x2":1593,"y2":1606,"stroke":1273,"style":1274},[126,1612],{"x1":1601,"y1":1605,"x2":1601,"y2":1606,"stroke":1273,"style":1274},[126,1614],{"x1":1578,"y1":1615,"x2":1616,"y2":1615,"stroke":78,"style":1617},"86","313","stroke-width:2;marker-end:url(#asqla-txn-arrow);",[87,1619,1621],{"x":120,"y":1620,"fill":91,"style":113},"78","resolve get_session",[126,1623],{"x1":1586,"y1":1624,"x2":1625,"y2":1624,"stroke":78,"style":1617},"122","513",[87,1627,1630],{"x":1628,"y":1629,"fill":91,"style":113},"414","114","checkout + BEGIN",[126,1632],{"x1":1593,"y1":1633,"x2":1634,"y2":1633,"stroke":78,"style":1617},"158","677",[87,1636,1638],{"x":1637,"y":179,"fill":91,"style":113},"596","SELECT \u002F INSERT",[80,1640],{"x":1641,"y":167,"width":1574,"height":1642,"rx":1643,"fill":101,"stroke":138,"style":103},"505","70","4",[126,1645],{"x1":1634,"y1":128,"x2":1646,"y2":128,"stroke":78,"style":1617},"517",[87,1648,1649],{"x":1637,"y":137,"fill":78,"style":113},"rows",[126,1651],{"x1":1593,"y1":1334,"x2":1634,"y2":1334,"stroke":78,"style":1617},[87,1653,1655],{"x":1637,"y":1654,"fill":138,"style":113},"232","COMMIT",[126,1657],{"x1":1625,"y1":1658,"x2":1659,"y2":1658,"stroke":78,"style":1617},"274","317",[87,1661,1663],{"x":1628,"y":1662,"fill":91,"style":113},"266","return connection",[80,1665],{"x":179,"y":1666,"width":181,"height":182,"rx":183,"fill":184,"stroke":185,"style":103},"292",[87,1668,1669],{"x":89,"y":1368,"fill":91,"style":113},"Any exception in the handler rolls back the whole block",[14,1671,1672,1673,1676],{},"The cost of this pattern is that a connection stays checked out for the entire handler, including any HTTP call you make to Stripe or an LLM provider in the middle of it. That is the fastest way to exhaust a pool: ten concurrent requests each holding a connection while waiting three seconds on a third-party API will stall every other request behind ",[37,1674,1675],{},"pool_timeout",". Never call an external service inside a database transaction. Read what you need, commit, then make the call, then open a second short transaction to record the result — or push the call into a background job entirely.",[14,1678,1679],{},"Here is a handler using the dependency, with a write path that stays inside one transaction:",[202,1681,1683],{"className":379,"code":1682,"language":381,"meta":207,"style":207},"import os\nimport uuid\nfrom fastapi import HTTPException\nfrom sqlalchemy import select\nfrom sqlalchemy.exc import IntegrityError\n\n\n@app.post(\"\u002Fv1\u002Fprojects\", status_code=201)\nasync def create_project(payload: ProjectIn, db: DbSession) -> ProjectOut:\n    existing = await db.scalar(\n        select(Project).where(\n            Project.owner_id == payload.owner_id,\n            Project.slug == payload.slug,\n        )\n    )\n    if existing is not None:\n        raise HTTPException(status_code=409, detail=\"slug already in use\")\n\n    project = Project(\n        id=uuid.uuid4(),\n        owner_id=payload.owner_id,\n        slug=payload.slug,\n        name=payload.name,\n    )\n    db.add(project)\n    try:\n        await db.flush()\n    except IntegrityError as exc:\n        raise HTTPException(status_code=409, detail=\"slug already in use\") from exc\n    return ProjectOut.model_validate(project, from_attributes=True)\n",[37,1684,1685,1691,1698,1709,1721,1733,1737,1741,1763,1775,1788,1793,1803,1813,1818,1823,1841,1868,1872,1882,1892,1902,1912,1922,1926,1931,1938,1946,1959,1986],{"__ignoreMap":207},[211,1686,1687,1689],{"class":126,"line":213},[211,1688,388],{"class":261},[211,1690,391],{"class":265},[211,1692,1693,1695],{"class":126,"line":275},[211,1694,388],{"class":261},[211,1696,1697],{"class":265}," uuid\n",[211,1699,1700,1702,1704,1706],{"class":126,"line":288},[211,1701,396],{"class":261},[211,1703,1146],{"class":265},[211,1705,388],{"class":261},[211,1707,1708],{"class":265}," HTTPException\n",[211,1710,1711,1713,1716,1718],{"class":126,"line":300},[211,1712,396],{"class":261},[211,1714,1715],{"class":265}," sqlalchemy ",[211,1717,388],{"class":261},[211,1719,1720],{"class":265}," select\n",[211,1722,1723,1725,1728,1730],{"class":126,"line":313},[211,1724,396],{"class":261},[211,1726,1727],{"class":265}," sqlalchemy.exc ",[211,1729,388],{"class":261},[211,1731,1732],{"class":265}," IntegrityError\n",[211,1734,1735],{"class":126,"line":326},[211,1736,468],{"emptyLinePlaceholder":467},[211,1738,1739],{"class":126,"line":339},[211,1740,468],{"emptyLinePlaceholder":467},[211,1742,1743,1746,1748,1751,1753,1756,1758,1761],{"class":126,"line":432},[211,1744,1745],{"class":216},"@app.post",[211,1747,485],{"class":265},[211,1749,1750],{"class":220},"\"\u002Fv1\u002Fprojects\"",[211,1752,490],{"class":265},[211,1754,1755],{"class":805},"status_code",[211,1757,269],{"class":261},[211,1759,1760],{"class":527},"201",[211,1762,435],{"class":265},[211,1764,1765,1767,1769,1772],{"class":126,"line":438},[211,1766,1169],{"class":261},[211,1768,1172],{"class":261},[211,1770,1771],{"class":216}," create_project",[211,1773,1774],{"class":265},"(payload: ProjectIn, db: DbSession) -> ProjectOut:\n",[211,1776,1777,1780,1782,1785],{"class":126,"line":451},[211,1778,1779],{"class":265},"    existing ",[211,1781,269],{"class":261},[211,1783,1784],{"class":261}," await",[211,1786,1787],{"class":265}," db.scalar(\n",[211,1789,1790],{"class":126,"line":464},[211,1791,1792],{"class":265},"        select(Project).where(\n",[211,1794,1795,1798,1800],{"class":126,"line":471},[211,1796,1797],{"class":265},"            Project.owner_id ",[211,1799,649],{"class":261},[211,1801,1802],{"class":265}," payload.owner_id,\n",[211,1804,1805,1808,1810],{"class":126,"line":476},[211,1806,1807],{"class":265},"            Project.slug ",[211,1809,649],{"class":261},[211,1811,1812],{"class":265}," payload.slug,\n",[211,1814,1815],{"class":126,"line":499},[211,1816,1817],{"class":265},"        )\n",[211,1819,1820],{"class":126,"line":505},[211,1821,1822],{"class":265},"    )\n",[211,1824,1825,1827,1830,1833,1836,1839],{"class":126,"line":510},[211,1826,561],{"class":261},[211,1828,1829],{"class":265}," existing ",[211,1831,1832],{"class":261},"is",[211,1834,1835],{"class":261}," not",[211,1837,1838],{"class":527}," None",[211,1840,531],{"class":265},[211,1842,1843,1846,1849,1851,1853,1856,1858,1861,1863,1866],{"class":126,"line":515},[211,1844,1845],{"class":261},"        raise",[211,1847,1848],{"class":265}," HTTPException(",[211,1850,1755],{"class":805},[211,1852,269],{"class":261},[211,1854,1855],{"class":527},"409",[211,1857,490],{"class":265},[211,1859,1860],{"class":805},"detail",[211,1862,269],{"class":261},[211,1864,1865],{"class":220},"\"slug already in use\"",[211,1867,435],{"class":265},[211,1869,1870],{"class":126,"line":534},[211,1871,468],{"emptyLinePlaceholder":467},[211,1873,1874,1877,1879],{"class":126,"line":551},[211,1875,1876],{"class":265},"    project ",[211,1878,269],{"class":261},[211,1880,1881],{"class":265}," Project(\n",[211,1883,1884,1887,1889],{"class":126,"line":558},[211,1885,1886],{"class":805},"        id",[211,1888,269],{"class":261},[211,1890,1891],{"class":265},"uuid.uuid4(),\n",[211,1893,1894,1897,1899],{"class":126,"line":572},[211,1895,1896],{"class":805},"        owner_id",[211,1898,269],{"class":261},[211,1900,1901],{"class":265},"payload.owner_id,\n",[211,1903,1904,1907,1909],{"class":126,"line":597},[211,1905,1906],{"class":805},"        slug",[211,1908,269],{"class":261},[211,1910,1911],{"class":265},"payload.slug,\n",[211,1913,1914,1917,1919],{"class":126,"line":606},[211,1915,1916],{"class":805},"        name",[211,1918,269],{"class":261},[211,1920,1921],{"class":265},"payload.name,\n",[211,1923,1924],{"class":126,"line":611},[211,1925,1822],{"class":265},[211,1927,1928],{"class":126,"line":616},[211,1929,1930],{"class":265},"    db.add(project)\n",[211,1932,1933,1936],{"class":126,"line":627},[211,1934,1935],{"class":261},"    try",[211,1937,531],{"class":265},[211,1939,1940,1943],{"class":126,"line":655},[211,1941,1942],{"class":261},"        await",[211,1944,1945],{"class":265}," db.flush()\n",[211,1947,1948,1951,1954,1956],{"class":126,"line":675},[211,1949,1950],{"class":261},"    except",[211,1952,1953],{"class":265}," IntegrityError ",[211,1955,1496],{"class":261},[211,1957,1958],{"class":265}," exc:\n",[211,1960,1961,1963,1965,1967,1969,1971,1973,1975,1977,1979,1981,1983],{"class":126,"line":686},[211,1962,1845],{"class":261},[211,1964,1848],{"class":265},[211,1966,1755],{"class":805},[211,1968,269],{"class":261},[211,1970,1855],{"class":527},[211,1972,490],{"class":265},[211,1974,1860],{"class":805},[211,1976,269],{"class":261},[211,1978,1865],{"class":220},[211,1980,646],{"class":265},[211,1982,396],{"class":261},[211,1984,1985],{"class":265}," exc\n",[211,1987,1988,1990,1993,1996,1998,2000],{"class":126,"line":695},[211,1989,600],{"class":261},[211,1991,1992],{"class":265}," ProjectOut.model_validate(project, ",[211,1994,1995],{"class":805},"from_attributes",[211,1997,269],{"class":261},[211,1999,992],{"class":527},[211,2001,435],{"class":265},[14,2003,2004,2005,2008,2009,2011],{},"Note the ",[37,2006,2007],{},"flush()"," rather than ",[37,2010,1410],{},". Flush pushes the INSERT so the unique constraint fires and you can turn it into a clean 409, while the dependency still owns the commit. Keep the unique index in the database as well as the pre-check — the pre-check is a nicety for the error message, the index is what actually prevents duplicates under concurrency.",[191,2013],{},[194,2015,2017],{"id":2016},"step-4-kill-n1-queries-before-they-reach-production","Step 4: Kill N+1 queries before they reach production",[14,2019,2020,2021,2024],{},"N+1 is the default behaviour of a relational ORM and the single largest source of latency in ORM-backed APIs. You select 200 projects, then serialize each one's ",[37,2022,2023],{},"owner",", and SQLAlchemy issues 200 extra queries. On a local database each is 0.9 ms and you never notice. Across a network to a managed Postgres each is 4 ms, and your p95 for that endpoint jumps from 15 ms to 800 ms — with 200x the query volume billed against your connection budget.",[14,2026,2027,2028,2030,2031,2034],{},"In async SQLAlchemy the failure mode is at least loud: a lazy load during serialization raises ",[37,2029,250],{}," rather than silently issuing queries. Lean into that. Declare relationships with ",[37,2032,2033],{},"lazy=\"raise_on_sql\""," so any accidental lazy load blows up in your test suite instead of degrading in production.",[202,2036,2038],{"className":379,"code":2037,"language":381,"meta":207,"style":207},"from datetime import datetime\nimport uuid\nfrom sqlalchemy import ForeignKey, String, func\nfrom sqlalchemy.orm import Mapped, mapped_column, relationship\nfrom sqlalchemy.dialects.postgresql import UUID\n\n\nclass Owner(Base):\n    __tablename__ = \"owners\"\n\n    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)\n    email: Mapped[str] = mapped_column(String(320), unique=True)\n    projects: Mapped[list[\"Project\"]] = relationship(\n        back_populates=\"owner\", lazy=\"raise_on_sql\"\n    )\n\n\nclass Project(Base):\n    __tablename__ = \"projects\"\n\n    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)\n    owner_id: Mapped[uuid.UUID] = mapped_column(\n        ForeignKey(\"owners.id\", ondelete=\"CASCADE\"), index=True\n    )\n    slug: Mapped[str] = mapped_column(String(64))\n    name: Mapped[str] = mapped_column(String(200))\n    created_at: Mapped[datetime] = mapped_column(server_default=func.now())\n    owner: Mapped[Owner] = relationship(back_populates=\"projects\", lazy=\"raise_on_sql\")\n",[37,2039,2040,2052,2058,2069,2080,2092,2096,2100,2114,2124,2128,2166,2193,2209,2229,2233,2237,2241,2254,2263,2267,2297,2311,2339,2343,2362,2380,2398],{"__ignoreMap":207},[211,2041,2042,2044,2047,2049],{"class":126,"line":213},[211,2043,396],{"class":261},[211,2045,2046],{"class":265}," datetime ",[211,2048,388],{"class":261},[211,2050,2051],{"class":265}," datetime\n",[211,2053,2054,2056],{"class":126,"line":275},[211,2055,388],{"class":261},[211,2057,1697],{"class":265},[211,2059,2060,2062,2064,2066],{"class":126,"line":288},[211,2061,396],{"class":261},[211,2063,1715],{"class":265},[211,2065,388],{"class":261},[211,2067,2068],{"class":265}," ForeignKey, String, func\n",[211,2070,2071,2073,2075,2077],{"class":126,"line":300},[211,2072,396],{"class":261},[211,2074,443],{"class":265},[211,2076,388],{"class":261},[211,2078,2079],{"class":265}," Mapped, mapped_column, relationship\n",[211,2081,2082,2084,2087,2089],{"class":126,"line":313},[211,2083,396],{"class":261},[211,2085,2086],{"class":265}," sqlalchemy.dialects.postgresql ",[211,2088,388],{"class":261},[211,2090,2091],{"class":527}," UUID\n",[211,2093,2094],{"class":126,"line":326},[211,2095,468],{"emptyLinePlaceholder":467},[211,2097,2098],{"class":126,"line":339},[211,2099,468],{"emptyLinePlaceholder":467},[211,2101,2102,2104,2107,2109,2112],{"class":126,"line":432},[211,2103,479],{"class":261},[211,2105,2106],{"class":216}," Owner",[211,2108,485],{"class":265},[211,2110,2111],{"class":216},"Base",[211,2113,496],{"class":265},[211,2115,2116,2119,2121],{"class":126,"line":438},[211,2117,2118],{"class":265},"    __tablename__ ",[211,2120,269],{"class":261},[211,2122,2123],{"class":220}," \"owners\"\n",[211,2125,2126],{"class":126,"line":451},[211,2127,468],{"emptyLinePlaceholder":467},[211,2129,2130,2133,2136,2139,2142,2144,2147,2150,2152,2154,2157,2160,2162,2164],{"class":126,"line":464},[211,2131,2132],{"class":527},"    id",[211,2134,2135],{"class":265},": Mapped[uuid.",[211,2137,2138],{"class":527},"UUID",[211,2140,2141],{"class":265},"] ",[211,2143,269],{"class":261},[211,2145,2146],{"class":265}," mapped_column(UUID(",[211,2148,2149],{"class":805},"as_uuid",[211,2151,269],{"class":261},[211,2153,992],{"class":527},[211,2155,2156],{"class":265},"), ",[211,2158,2159],{"class":805},"primary_key",[211,2161,269],{"class":261},[211,2163,992],{"class":527},[211,2165,435],{"class":265},[211,2167,2168,2171,2173,2175,2177,2180,2182,2184,2187,2189,2191],{"class":126,"line":471},[211,2169,2170],{"class":265},"    email: Mapped[",[211,2172,528],{"class":527},[211,2174,2141],{"class":265},[211,2176,269],{"class":261},[211,2178,2179],{"class":265}," mapped_column(String(",[211,2181,84],{"class":527},[211,2183,2156],{"class":265},[211,2185,2186],{"class":805},"unique",[211,2188,269],{"class":261},[211,2190,992],{"class":527},[211,2192,435],{"class":265},[211,2194,2195,2198,2201,2204,2206],{"class":126,"line":476},[211,2196,2197],{"class":265},"    projects: Mapped[list[",[211,2199,2200],{"class":220},"\"Project\"",[211,2202,2203],{"class":265},"]] ",[211,2205,269],{"class":261},[211,2207,2208],{"class":265}," relationship(\n",[211,2210,2211,2214,2216,2219,2221,2224,2226],{"class":126,"line":499},[211,2212,2213],{"class":805},"        back_populates",[211,2215,269],{"class":261},[211,2217,2218],{"class":220},"\"owner\"",[211,2220,490],{"class":265},[211,2222,2223],{"class":805},"lazy",[211,2225,269],{"class":261},[211,2227,2228],{"class":220},"\"raise_on_sql\"\n",[211,2230,2231],{"class":126,"line":505},[211,2232,1822],{"class":265},[211,2234,2235],{"class":126,"line":510},[211,2236,468],{"emptyLinePlaceholder":467},[211,2238,2239],{"class":126,"line":515},[211,2240,468],{"emptyLinePlaceholder":467},[211,2242,2243,2245,2248,2250,2252],{"class":126,"line":534},[211,2244,479],{"class":261},[211,2246,2247],{"class":216}," Project",[211,2249,485],{"class":265},[211,2251,2111],{"class":216},[211,2253,496],{"class":265},[211,2255,2256,2258,2260],{"class":126,"line":551},[211,2257,2118],{"class":265},[211,2259,269],{"class":261},[211,2261,2262],{"class":220}," \"projects\"\n",[211,2264,2265],{"class":126,"line":558},[211,2266,468],{"emptyLinePlaceholder":467},[211,2268,2269,2271,2273,2275,2277,2279,2281,2283,2285,2287,2289,2291,2293,2295],{"class":126,"line":572},[211,2270,2132],{"class":527},[211,2272,2135],{"class":265},[211,2274,2138],{"class":527},[211,2276,2141],{"class":265},[211,2278,269],{"class":261},[211,2280,2146],{"class":265},[211,2282,2149],{"class":805},[211,2284,269],{"class":261},[211,2286,992],{"class":527},[211,2288,2156],{"class":265},[211,2290,2159],{"class":805},[211,2292,269],{"class":261},[211,2294,992],{"class":527},[211,2296,435],{"class":265},[211,2298,2299,2302,2304,2306,2308],{"class":126,"line":597},[211,2300,2301],{"class":265},"    owner_id: Mapped[uuid.",[211,2303,2138],{"class":527},[211,2305,2141],{"class":265},[211,2307,269],{"class":261},[211,2309,2310],{"class":265}," mapped_column(\n",[211,2312,2313,2316,2319,2321,2324,2326,2329,2331,2334,2336],{"class":126,"line":606},[211,2314,2315],{"class":265},"        ForeignKey(",[211,2317,2318],{"class":220},"\"owners.id\"",[211,2320,490],{"class":265},[211,2322,2323],{"class":805},"ondelete",[211,2325,269],{"class":261},[211,2327,2328],{"class":220},"\"CASCADE\"",[211,2330,2156],{"class":265},[211,2332,2333],{"class":805},"index",[211,2335,269],{"class":261},[211,2337,2338],{"class":527},"True\n",[211,2340,2341],{"class":126,"line":611},[211,2342,1822],{"class":265},[211,2344,2345,2348,2350,2352,2354,2356,2359],{"class":126,"line":616},[211,2346,2347],{"class":265},"    slug: Mapped[",[211,2349,528],{"class":527},[211,2351,2141],{"class":265},[211,2353,269],{"class":261},[211,2355,2179],{"class":265},[211,2357,2358],{"class":527},"64",[211,2360,2361],{"class":265},"))\n",[211,2363,2364,2367,2369,2371,2373,2375,2378],{"class":126,"line":627},[211,2365,2366],{"class":265},"    name: Mapped[",[211,2368,528],{"class":527},[211,2370,2141],{"class":265},[211,2372,269],{"class":261},[211,2374,2179],{"class":265},[211,2376,2377],{"class":527},"200",[211,2379,2361],{"class":265},[211,2381,2382,2385,2387,2390,2393,2395],{"class":126,"line":655},[211,2383,2384],{"class":265},"    created_at: Mapped[datetime] ",[211,2386,269],{"class":261},[211,2388,2389],{"class":265}," mapped_column(",[211,2391,2392],{"class":805},"server_default",[211,2394,269],{"class":261},[211,2396,2397],{"class":265},"func.now())\n",[211,2399,2400,2403,2405,2408,2411,2413,2416,2418,2420,2422,2425],{"class":126,"line":675},[211,2401,2402],{"class":265},"    owner: Mapped[Owner] ",[211,2404,269],{"class":261},[211,2406,2407],{"class":265}," relationship(",[211,2409,2410],{"class":805},"back_populates",[211,2412,269],{"class":261},[211,2414,2415],{"class":220},"\"projects\"",[211,2417,490],{"class":265},[211,2419,2223],{"class":805},[211,2421,269],{"class":261},[211,2423,2424],{"class":220},"\"raise_on_sql\"",[211,2426,435],{"class":265},[14,2428,2429,2430,2433,2434,2437,2438,2440,2441,2444,2445,2447,2448,1394],{},"Now every query states its loading strategy explicitly. Use ",[37,2431,2432],{},"selectinload"," for collections and ",[37,2435,2436],{},"joinedload"," for many-to-one scalars. ",[37,2439,2432],{}," issues a second query with an ",[37,2442,2443],{},"IN"," clause, which stays fast and avoids the row multiplication that a join across a collection causes. ",[37,2446,2436],{}," folds a single related row into the same query, which is ideal for ",[37,2449,2450],{},"project.owner",[202,2452,2454],{"className":379,"code":2453,"language":381,"meta":207,"style":207},"from sqlalchemy.orm import joinedload, selectinload\n\n\nasync def list_projects(db: AsyncSession, owner_id: uuid.UUID, limit: int = 200):\n    stmt = (\n        select(Project)\n        .where(Project.owner_id == owner_id)\n        .options(joinedload(Project.owner))\n        .order_by(Project.created_at.desc())\n        .limit(limit)\n    )\n    result = await db.scalars(stmt)\n    return result.all()\n\n\nasync def list_owners_with_projects(db: AsyncSession, limit: int = 50):\n    stmt = (\n        select(Owner)\n        .options(selectinload(Owner.projects))\n        .order_by(Owner.email)\n        .limit(limit)\n    )\n    result = await db.scalars(stmt)\n    return result.unique().all()\n",[37,2455,2456,2467,2471,2475,2502,2511,2516,2526,2531,2536,2541,2545,2557,2564,2568,2572,2593,2601,2606,2611,2616,2620,2624,2634],{"__ignoreMap":207},[211,2457,2458,2460,2462,2464],{"class":126,"line":213},[211,2459,396],{"class":261},[211,2461,443],{"class":265},[211,2463,388],{"class":261},[211,2465,2466],{"class":265}," joinedload, selectinload\n",[211,2468,2469],{"class":126,"line":275},[211,2470,468],{"emptyLinePlaceholder":467},[211,2472,2473],{"class":126,"line":288},[211,2474,468],{"emptyLinePlaceholder":467},[211,2476,2477,2479,2481,2484,2487,2489,2492,2494,2497,2500],{"class":126,"line":300},[211,2478,1169],{"class":261},[211,2480,1172],{"class":261},[211,2482,2483],{"class":216}," list_projects",[211,2485,2486],{"class":265},"(db: AsyncSession, owner_id: uuid.",[211,2488,2138],{"class":527},[211,2490,2491],{"class":265},", limit: ",[211,2493,905],{"class":527},[211,2495,2496],{"class":261}," =",[211,2498,2499],{"class":527}," 200",[211,2501,496],{"class":265},[211,2503,2504,2507,2509],{"class":126,"line":313},[211,2505,2506],{"class":265},"    stmt ",[211,2508,269],{"class":261},[211,2510,404],{"class":265},[211,2512,2513],{"class":126,"line":326},[211,2514,2515],{"class":265},"        select(Project)\n",[211,2517,2518,2521,2523],{"class":126,"line":339},[211,2519,2520],{"class":265},"        .where(Project.owner_id ",[211,2522,649],{"class":261},[211,2524,2525],{"class":265}," owner_id)\n",[211,2527,2528],{"class":126,"line":432},[211,2529,2530],{"class":265},"        .options(joinedload(Project.owner))\n",[211,2532,2533],{"class":126,"line":438},[211,2534,2535],{"class":265},"        .order_by(Project.created_at.desc())\n",[211,2537,2538],{"class":126,"line":451},[211,2539,2540],{"class":265},"        .limit(limit)\n",[211,2542,2543],{"class":126,"line":464},[211,2544,1822],{"class":265},[211,2546,2547,2550,2552,2554],{"class":126,"line":471},[211,2548,2549],{"class":265},"    result ",[211,2551,269],{"class":261},[211,2553,1784],{"class":261},[211,2555,2556],{"class":265}," db.scalars(stmt)\n",[211,2558,2559,2561],{"class":126,"line":476},[211,2560,600],{"class":261},[211,2562,2563],{"class":265}," result.all()\n",[211,2565,2566],{"class":126,"line":499},[211,2567,468],{"emptyLinePlaceholder":467},[211,2569,2570],{"class":126,"line":505},[211,2571,468],{"emptyLinePlaceholder":467},[211,2573,2574,2576,2578,2581,2584,2586,2588,2591],{"class":126,"line":510},[211,2575,1169],{"class":261},[211,2577,1172],{"class":261},[211,2579,2580],{"class":216}," list_owners_with_projects",[211,2582,2583],{"class":265},"(db: AsyncSession, limit: ",[211,2585,905],{"class":527},[211,2587,2496],{"class":261},[211,2589,2590],{"class":527}," 50",[211,2592,496],{"class":265},[211,2594,2595,2597,2599],{"class":126,"line":515},[211,2596,2506],{"class":265},[211,2598,269],{"class":261},[211,2600,404],{"class":265},[211,2602,2603],{"class":126,"line":534},[211,2604,2605],{"class":265},"        select(Owner)\n",[211,2607,2608],{"class":126,"line":551},[211,2609,2610],{"class":265},"        .options(selectinload(Owner.projects))\n",[211,2612,2613],{"class":126,"line":558},[211,2614,2615],{"class":265},"        .order_by(Owner.email)\n",[211,2617,2618],{"class":126,"line":572},[211,2619,2540],{"class":265},[211,2621,2622],{"class":126,"line":597},[211,2623,1822],{"class":265},[211,2625,2626,2628,2630,2632],{"class":126,"line":606},[211,2627,2549],{"class":265},[211,2629,269],{"class":261},[211,2631,1784],{"class":261},[211,2633,2556],{"class":265},[211,2635,2636,2638],{"class":126,"line":611},[211,2637,600],{"class":261},[211,2639,2640],{"class":265}," result.unique().all()\n",[14,2642,2643],{},"The numbers below come from a 200-row listing against a managed Postgres roughly 4 ms of round trip away. The shape holds on any remote database: the fix is not micro-optimisation, it is deleting 200 network hops.",[42,2645,51,2649,51,2652,51,2655,51,2657,51,2660,51,2663,51,2666,51,2671,51,2675,51,2679,51,2683,51,2688,51,2692,51,2695,51,2699,51,2703,51,2707,51,2709,51,2712,51,2717,51,2721,51,2723,51,2726],{"viewBox":44,"role":45,"ariaLabelledBy":2646,"xmlns":49,"style":50},[2647,2648],"asqla-nplus1-t","asqla-nplus1-d",[53,2650,2651],{"id":2647},"Latency of three loading strategies",[57,2653,2654],{"id":2648},"A bar chart comparing a lazy N plus 1 load at 201 queries and 812 milliseconds, selectinload at 2 queries and 19 milliseconds, and joinedload at 1 query and 12 milliseconds for the same 200-row listing.",[80,2656],{"x":82,"y":82,"width":83,"height":84,"fill":85},[87,2658,2659],{"x":89,"y":90,"fill":91,"style":92},"Same 200-row listing, three loading strategies",[126,2661],{"x1":2662,"y1":1583,"x2":1307,"y2":1583,"stroke":1273,"style":103},"90",[126,2664],{"x1":2662,"y1":2665,"x2":2662,"y2":1583,"stroke":1273,"style":103},"50",[87,2667,82],{"x":2668,"y":2669,"fill":78,"style":2670},"80","254","text-anchor:end;font-size:11;font-family:var(--font-sans);",[87,2672,2674],{"x":2668,"y":2673,"fill":78,"style":2670},"154","400",[87,2676,2678],{"x":2668,"y":2677,"fill":78,"style":2670},"58","800",[87,2680,2682],{"x":182,"y":1320,"fill":78,"style":2681},"font-size:11;font-family:var(--font-sans);","ms",[80,2684],{"x":167,"y":2685,"width":2686,"height":2687,"rx":1643,"fill":163,"stroke":163,"style":103},"47","120","203",[87,2689,2691],{"x":2377,"y":2690,"fill":91,"style":113},"38","812 ms",[87,2693,2694],{"x":2377,"y":180,"fill":91,"style":113},"lazy (N+1)",[87,2696,2698],{"x":2377,"y":1345,"fill":78,"style":2697},"text-anchor:middle;font-size:11;font-family:var(--font-sans);","201 queries",[80,2700],{"x":1571,"y":2701,"width":2686,"height":70,"rx":2702,"fill":138,"stroke":138,"style":103},"245","2",[87,2704,2706],{"x":2674,"y":2705,"fill":91,"style":113},"236","19 ms",[87,2708,2432],{"x":2674,"y":180,"fill":91,"style":113},[87,2710,2711],{"x":2674,"y":1345,"fill":78,"style":2697},"2 queries",[80,2713],{"x":2714,"y":2715,"width":2686,"height":2716,"rx":592,"fill":102,"stroke":102,"style":103},"540","247","3",[87,2718,2720],{"x":2719,"y":2705,"fill":91,"style":113},"600","12 ms",[87,2722,2436],{"x":2719,"y":180,"fill":91,"style":113},[87,2724,2725],{"x":2719,"y":1345,"fill":78,"style":2697},"1 query",[87,2727,2728],{"x":89,"y":1606,"fill":78,"style":2697},"Measured against a managed Postgres with 4 ms round trip",[14,2730,2731,2732,2735,2736,2738,2739,2743,2744,2748],{},"Two rules keep this honest long term. First, call ",[37,2733,2734],{},".unique()"," on any result that used ",[37,2737,2436],{}," against a collection, or SQLAlchemy raises rather than hand you duplicated parents. Second, add a per-request query counter to your logs so a regression shows up in a dashboard rather than in a support ticket; the ",[18,2740,2742],{"href":2741},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002F","monitoring and logging setup"," page covers wiring it into structured logs. If a listing endpoint stays hot and its data changes rarely, the next lever is ",[18,2745,2747],{"href":2746},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F","caching responses with Redis"," so the query does not run at all.",[191,2750],{},[194,2752,2754],{"id":2753},"step-5-run-alembic-migrations-against-the-async-engine","Step 5: Run Alembic migrations against the async engine",[14,2756,2757,2758,2761,2762,2765],{},"Alembic ships an async template. Use it — hand-rolling ",[37,2759,2760],{},"asyncio.run()"," inside ",[37,2763,2764],{},"env.py"," is how people end up with migrations that hang on a closed loop.",[202,2767,2769],{"className":204,"code":2768,"language":206,"meta":207,"style":207},"alembic init -t async migrations\nalembic revision --autogenerate -m \"create projects\"\nalembic upgrade head\n",[37,2770,2771,2788,2804],{"__ignoreMap":207},[211,2772,2773,2776,2779,2782,2785],{"class":126,"line":213},[211,2774,2775],{"class":216},"alembic",[211,2777,2778],{"class":220}," init",[211,2780,2781],{"class":527}," -t",[211,2783,2784],{"class":220}," async",[211,2786,2787],{"class":220}," migrations\n",[211,2789,2790,2792,2795,2798,2801],{"class":126,"line":275},[211,2791,2775],{"class":216},[211,2793,2794],{"class":220}," revision",[211,2796,2797],{"class":527}," --autogenerate",[211,2799,2800],{"class":527}," -m",[211,2802,2803],{"class":220}," \"create projects\"\n",[211,2805,2806,2808,2811],{"class":126,"line":288},[211,2807,2775],{"class":216},[211,2809,2810],{"class":220}," upgrade",[211,2812,2813],{"class":220}," head\n",[14,2815,2816,2817,2820],{},"Point Alembic at the same environment variable your app reads, so there is exactly one source of truth for the connection string. Edit ",[37,2818,2819],{},"migrations\u002Fenv.py",":",[202,2822,2824],{"className":379,"code":2823,"language":381,"meta":207,"style":207},"import asyncio\nimport os\nfrom alembic import context\nfrom sqlalchemy.ext.asyncio import async_engine_from_config\nfrom sqlalchemy import pool\n\nfrom app.db import Base, _url\nfrom app import models  # noqa: F401 — registers tables on Base.metadata\n\nconfig = context.config\nconfig.set_main_option(\"sqlalchemy.url\", _url())\ntarget_metadata = Base.metadata\n\n\ndef do_run_migrations(connection) -> None:\n    context.configure(\n        connection=connection,\n        target_metadata=target_metadata,\n        compare_type=True,\n        compare_server_default=True,\n    )\n    with context.begin_transaction():\n        context.run_migrations()\n\n\nasync def run_async_migrations() -> None:\n    connectable = async_engine_from_config(\n        config.get_section(config.config_ini_section, {}),\n        prefix=\"sqlalchemy.\",\n        poolclass=pool.NullPool,\n    )\n    async with connectable.connect() as connection:\n        await connection.run_sync(do_run_migrations)\n    await connectable.dispose()\n\n\nasyncio.run(run_async_migrations())\n",[37,2825,2826,2833,2839,2851,2862,2873,2877,2889,2904,2908,2918,2929,2939,2943,2947,2962,2967,2977,2987,2998,3009,3013,3021,3026,3030,3034,3049,3059,3064,3076,3086,3090,3104,3111,3118,3122,3126],{"__ignoreMap":207},[211,2827,2828,2830],{"class":126,"line":213},[211,2829,388],{"class":261},[211,2831,2832],{"class":265}," asyncio\n",[211,2834,2835,2837],{"class":126,"line":275},[211,2836,388],{"class":261},[211,2838,391],{"class":265},[211,2840,2841,2843,2846,2848],{"class":126,"line":288},[211,2842,396],{"class":261},[211,2844,2845],{"class":265}," alembic ",[211,2847,388],{"class":261},[211,2849,2850],{"class":265}," context\n",[211,2852,2853,2855,2857,2859],{"class":126,"line":300},[211,2854,396],{"class":261},[211,2856,399],{"class":265},[211,2858,388],{"class":261},[211,2860,2861],{"class":265}," async_engine_from_config\n",[211,2863,2864,2866,2868,2870],{"class":126,"line":313},[211,2865,396],{"class":261},[211,2867,1715],{"class":265},[211,2869,388],{"class":261},[211,2871,2872],{"class":265}," pool\n",[211,2874,2875],{"class":126,"line":326},[211,2876,468],{"emptyLinePlaceholder":467},[211,2878,2879,2881,2884,2886],{"class":126,"line":339},[211,2880,396],{"class":261},[211,2882,2883],{"class":265}," app.db ",[211,2885,388],{"class":261},[211,2887,2888],{"class":265}," Base, _url\n",[211,2890,2891,2893,2896,2898,2901],{"class":126,"line":432},[211,2892,396],{"class":261},[211,2894,2895],{"class":265}," app ",[211,2897,388],{"class":261},[211,2899,2900],{"class":265}," models  ",[211,2902,2903],{"class":554},"# noqa: F401 — registers tables on Base.metadata\n",[211,2905,2906],{"class":126,"line":438},[211,2907,468],{"emptyLinePlaceholder":467},[211,2909,2910,2913,2915],{"class":126,"line":451},[211,2911,2912],{"class":265},"config ",[211,2914,269],{"class":261},[211,2916,2917],{"class":265}," context.config\n",[211,2919,2920,2923,2926],{"class":126,"line":464},[211,2921,2922],{"class":265},"config.set_main_option(",[211,2924,2925],{"class":220},"\"sqlalchemy.url\"",[211,2927,2928],{"class":265},", _url())\n",[211,2930,2931,2934,2936],{"class":126,"line":471},[211,2932,2933],{"class":265},"target_metadata ",[211,2935,269],{"class":261},[211,2937,2938],{"class":265}," Base.metadata\n",[211,2940,2941],{"class":126,"line":476},[211,2942,468],{"emptyLinePlaceholder":467},[211,2944,2945],{"class":126,"line":499},[211,2946,468],{"emptyLinePlaceholder":467},[211,2948,2949,2951,2954,2957,2960],{"class":126,"line":505},[211,2950,518],{"class":261},[211,2952,2953],{"class":216}," do_run_migrations",[211,2955,2956],{"class":265},"(connection) -> ",[211,2958,2959],{"class":527},"None",[211,2961,531],{"class":265},[211,2963,2964],{"class":126,"line":510},[211,2965,2966],{"class":265},"    context.configure(\n",[211,2968,2969,2972,2974],{"class":126,"line":515},[211,2970,2971],{"class":805},"        connection",[211,2973,269],{"class":261},[211,2975,2976],{"class":265},"connection,\n",[211,2978,2979,2982,2984],{"class":126,"line":534},[211,2980,2981],{"class":805},"        target_metadata",[211,2983,269],{"class":261},[211,2985,2986],{"class":265},"target_metadata,\n",[211,2988,2989,2992,2994,2996],{"class":126,"line":551},[211,2990,2991],{"class":805},"        compare_type",[211,2993,269],{"class":261},[211,2995,992],{"class":527},[211,2997,866],{"class":265},[211,2999,3000,3003,3005,3007],{"class":126,"line":558},[211,3001,3002],{"class":805},"        compare_server_default",[211,3004,269],{"class":261},[211,3006,992],{"class":527},[211,3008,866],{"class":265},[211,3010,3011],{"class":126,"line":572},[211,3012,1822],{"class":265},[211,3014,3015,3018],{"class":126,"line":597},[211,3016,3017],{"class":261},"    with",[211,3019,3020],{"class":265}," context.begin_transaction():\n",[211,3022,3023],{"class":126,"line":606},[211,3024,3025],{"class":265},"        context.run_migrations()\n",[211,3027,3028],{"class":126,"line":611},[211,3029,468],{"emptyLinePlaceholder":467},[211,3031,3032],{"class":126,"line":616},[211,3033,468],{"emptyLinePlaceholder":467},[211,3035,3036,3038,3040,3043,3045,3047],{"class":126,"line":627},[211,3037,1169],{"class":261},[211,3039,1172],{"class":261},[211,3041,3042],{"class":216}," run_async_migrations",[211,3044,524],{"class":265},[211,3046,2959],{"class":527},[211,3048,531],{"class":265},[211,3050,3051,3054,3056],{"class":126,"line":655},[211,3052,3053],{"class":265},"    connectable ",[211,3055,269],{"class":261},[211,3057,3058],{"class":265}," async_engine_from_config(\n",[211,3060,3061],{"class":126,"line":675},[211,3062,3063],{"class":265},"        config.get_section(config.config_ini_section, {}),\n",[211,3065,3066,3069,3071,3074],{"class":126,"line":686},[211,3067,3068],{"class":805},"        prefix",[211,3070,269],{"class":261},[211,3072,3073],{"class":220},"\"sqlalchemy.\"",[211,3075,866],{"class":265},[211,3077,3078,3081,3083],{"class":126,"line":695},[211,3079,3080],{"class":805},"        poolclass",[211,3082,269],{"class":261},[211,3084,3085],{"class":265},"pool.NullPool,\n",[211,3087,3088],{"class":126,"line":715},[211,3089,1822],{"class":265},[211,3091,3092,3094,3096,3099,3101],{"class":126,"line":724},[211,3093,1487],{"class":261},[211,3095,1490],{"class":261},[211,3097,3098],{"class":265}," connectable.connect() ",[211,3100,1496],{"class":261},[211,3102,3103],{"class":265}," connection:\n",[211,3105,3106,3108],{"class":126,"line":730},[211,3107,1942],{"class":261},[211,3109,3110],{"class":265}," connection.run_sync(do_run_migrations)\n",[211,3112,3113,3115],{"class":126,"line":756},[211,3114,1188],{"class":261},[211,3116,3117],{"class":265}," connectable.dispose()\n",[211,3119,3120],{"class":126,"line":762},[211,3121,468],{"emptyLinePlaceholder":467},[211,3123,3124],{"class":126,"line":767},[211,3125,468],{"emptyLinePlaceholder":467},[211,3127,3128],{"class":126,"line":776},[211,3129,3130],{"class":265},"asyncio.run(run_async_migrations())\n",[14,3132,3133,3136,3137,3140,3141,3144,3145,3148],{},[37,3134,3135],{},"compare_type=True"," and ",[37,3138,3139],{},"compare_server_default=True"," are worth turning on: without them autogenerate misses a ",[37,3142,3143],{},"String(64)"," widening to ",[37,3146,3147],{},"String(200)"," and you ship a migration that does nothing. Always read the generated file before committing it — autogenerate is a first draft, not an oracle. It routinely wants to drop indexes it does not know about and rename tables it should leave alone.",[14,3150,3151,3152,3155,3156,3159],{},"The dangerous migrations are the ones that take a lock. ",[37,3153,3154],{},"ALTER TABLE ... ADD COLUMN NOT NULL DEFAULT"," rewrites the whole table on older Postgres versions and holds an ",[37,3157,3158],{},"ACCESS EXCLUSIVE"," lock the entire time; on a million-row table that is a multi-minute outage. Guard every migration with a lock timeout so it fails fast instead of queueing every query behind it, and build indexes concurrently:",[202,3161,3163],{"className":379,"code":3162,"language":381,"meta":207,"style":207},"from alembic import op\nimport sqlalchemy as sa\n\n\ndef upgrade() -> None:\n    op.execute(\"SET lock_timeout = '3s'\")\n    op.add_column(\"projects\", sa.Column(\"archived_at\", sa.DateTime(timezone=True)))\n    with op.get_context().autocommit_block():\n        op.create_index(\n            \"ix_projects_archived_at\",\n            \"projects\",\n            [\"archived_at\"],\n            postgresql_concurrently=True,\n        )\n",[37,3164,3165,3176,3187,3191,3195,3207,3217,3243,3250,3255,3262,3269,3279,3290],{"__ignoreMap":207},[211,3166,3167,3169,3171,3173],{"class":126,"line":213},[211,3168,396],{"class":261},[211,3170,2845],{"class":265},[211,3172,388],{"class":261},[211,3174,3175],{"class":265}," op\n",[211,3177,3178,3180,3182,3184],{"class":126,"line":275},[211,3179,388],{"class":261},[211,3181,1715],{"class":265},[211,3183,1496],{"class":261},[211,3185,3186],{"class":265}," sa\n",[211,3188,3189],{"class":126,"line":288},[211,3190,468],{"emptyLinePlaceholder":467},[211,3192,3193],{"class":126,"line":300},[211,3194,468],{"emptyLinePlaceholder":467},[211,3196,3197,3199,3201,3203,3205],{"class":126,"line":313},[211,3198,518],{"class":261},[211,3200,2810],{"class":216},[211,3202,524],{"class":265},[211,3204,2959],{"class":527},[211,3206,531],{"class":265},[211,3208,3209,3212,3215],{"class":126,"line":326},[211,3210,3211],{"class":265},"    op.execute(",[211,3213,3214],{"class":220},"\"SET lock_timeout = '3s'\"",[211,3216,435],{"class":265},[211,3218,3219,3222,3224,3227,3230,3233,3236,3238,3240],{"class":126,"line":339},[211,3220,3221],{"class":265},"    op.add_column(",[211,3223,2415],{"class":220},[211,3225,3226],{"class":265},", sa.Column(",[211,3228,3229],{"class":220},"\"archived_at\"",[211,3231,3232],{"class":265},", sa.DateTime(",[211,3234,3235],{"class":805},"timezone",[211,3237,269],{"class":261},[211,3239,992],{"class":527},[211,3241,3242],{"class":265},")))\n",[211,3244,3245,3247],{"class":126,"line":432},[211,3246,3017],{"class":261},[211,3248,3249],{"class":265}," op.get_context().autocommit_block():\n",[211,3251,3252],{"class":126,"line":438},[211,3253,3254],{"class":265},"        op.create_index(\n",[211,3256,3257,3260],{"class":126,"line":451},[211,3258,3259],{"class":220},"            \"ix_projects_archived_at\"",[211,3261,866],{"class":265},[211,3263,3264,3267],{"class":126,"line":464},[211,3265,3266],{"class":220},"            \"projects\"",[211,3268,866],{"class":265},[211,3270,3271,3274,3276],{"class":126,"line":471},[211,3272,3273],{"class":265},"            [",[211,3275,3229],{"class":220},[211,3277,3278],{"class":265},"],\n",[211,3280,3281,3284,3286,3288],{"class":126,"line":476},[211,3282,3283],{"class":805},"            postgresql_concurrently",[211,3285,269],{"class":261},[211,3287,992],{"class":527},[211,3289,866],{"class":265},[211,3291,3292],{"class":126,"line":499},[211,3293,1817],{"class":265},[14,3295,3296,3297,3301],{},"For anything that changes an existing column's meaning, use expand and contract: add the new column as nullable, deploy code that writes both old and new, backfill in batches, deploy code that reads only the new column, then drop the old one in a later release. Each step is independently reversible and none of them requires the app and the schema to change at the same instant — which is what makes ",[18,3298,3300],{"href":3299},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","zero-downtime deploys"," possible at all.",[42,3303,51,3308,51,3311,51,3314,51,3317,51,3320,51,3323,51,3327,51,3330,51,3333,51,3337,51,3339,51,3341,51,3344,51,3347,51,3350,51,3352,51,3355,51,3358,51,3360,51,3362,51,3365,51,3368,51,3371,51,3373,51,3376,51,3379,51,3384,51,3389,51,3392],{"viewBox":3304,"role":45,"ariaLabelledBy":3305,"xmlns":49,"style":50},"0 0 760 280",[3306,3307],"asqla-mig-t","asqla-mig-d",[53,3309,3310],{"id":3306},"Expand and contract migration timeline",[57,3312,3313],{"id":3307},"A five-stage timeline: add a nullable column, deploy dual-write code, backfill in batches, deploy read-new code, then drop the old column in a later release.",[80,3315],{"x":82,"y":82,"width":83,"height":3316,"fill":85},"280",[87,3318,3319],{"x":89,"y":182,"fill":91,"style":92},"Expand, migrate, contract — five safe releases",[126,3321],{"x1":97,"y1":2686,"x2":1271,"y2":2686,"stroke":1273,"style":3322},"stroke-width:3;",[3324,3325],"circle",{"cx":2662,"cy":2686,"r":3326,"fill":85,"stroke":138,"style":3322},"13",[87,3328,592],{"x":2662,"y":3329,"fill":91,"style":2697},"125",[87,3331,3332],{"x":2662,"y":171,"fill":91,"style":113},"Add column",[87,3334,3336],{"x":2662,"y":3335,"fill":78,"style":2697},"182","nullable",[3324,3338],{"cx":1334,"cy":2686,"r":3326,"fill":85,"stroke":138,"style":3322},[87,3340,2702],{"x":1334,"y":3329,"fill":91,"style":2697},[87,3342,3343],{"x":1334,"y":171,"fill":91,"style":113},"Dual write",[87,3345,3346],{"x":1334,"y":3335,"fill":78,"style":2697},"old + new",[3324,3348],{"cx":3349,"cy":2686,"r":3326,"fill":85,"stroke":185,"style":3322},"390",[87,3351,2716],{"x":3349,"y":3329,"fill":91,"style":2697},[87,3353,3354],{"x":3349,"y":171,"fill":91,"style":113},"Backfill",[87,3356,3357],{"x":3349,"y":3335,"fill":78,"style":2697},"batched job",[3324,3359],{"cx":2714,"cy":2686,"r":3326,"fill":85,"stroke":102,"style":3322},[87,3361,1643],{"x":2714,"y":3329,"fill":91,"style":2697},[87,3363,3364],{"x":2714,"y":171,"fill":91,"style":113},"Read new",[87,3366,3367],{"x":2714,"y":3335,"fill":78,"style":2697},"stop reading old",[3324,3369],{"cx":3370,"cy":2686,"r":3326,"fill":85,"stroke":163,"style":3322},"690",[87,3372,70],{"x":3370,"y":3329,"fill":91,"style":2697},[87,3374,3375],{"x":3370,"y":171,"fill":91,"style":113},"Drop old",[87,3377,3378],{"x":3370,"y":3335,"fill":78,"style":2697},"next release",[80,3380],{"x":97,"y":3381,"width":3382,"height":3383,"rx":183,"fill":184,"stroke":138,"style":103},"66","480","26",[87,3385,3388],{"x":3386,"y":3387,"fill":91,"style":113},"300","84","Rollback is safe anywhere in this window",[80,3390],{"x":179,"y":3391,"width":181,"height":182,"rx":183,"fill":184,"stroke":185,"style":103},"216",[87,3393,3394],{"x":89,"y":2705,"fill":91,"style":113},"Every step runs with SET lock_timeout = '3s'",[191,3396],{},[194,3398,3400],{"id":3399},"configuration-reference","Configuration reference",[3402,3403,3404,3420],"table",{},[3405,3406,3407],"thead",{},[3408,3409,3410,3414,3417],"tr",{},[3411,3412,3413],"th",{},"Variable",[3411,3415,3416],{},"Default",[3411,3418,3419],{},"Production recommendation",[3421,3422,3423,3439,3455,3471,3490,3507,3523,3540,3556],"tbody",{},[3408,3424,3425,3431,3433],{},[3426,3427,3428],"td",{},[37,3429,3430],{},"DATABASE_URL",[3426,3432,184],{},[3426,3434,3435,3438],{},[37,3436,3437],{},"postgresql+asyncpg:\u002F\u002F…",", from a secret store",[3408,3440,3441,3446,3450],{},[3426,3442,3443],{},[37,3444,3445],{},"DB_POOL_SIZE",[3426,3447,3448],{},[37,3449,70],{},[3426,3451,3452,3454],{},[37,3453,70],{}," per worker process",[3408,3456,3457,3462,3466],{},[3426,3458,3459],{},[37,3460,3461],{},"DB_MAX_OVERFLOW",[3426,3463,3464],{},[37,3465,70],{},[3426,3467,3468,3470],{},[37,3469,70],{},"; keep total under the server cap",[3408,3472,3473,3478,3482],{},[3426,3474,3475],{},[37,3476,3477],{},"DB_POOL_TIMEOUT",[3426,3479,3480],{},[37,3481,100],{},[3426,3483,3484,3486,3487,3489],{},[37,3485,70],{},"–",[37,3488,100],{}," seconds, fail fast",[3408,3491,3492,3497,3502],{},[3426,3493,3494],{},[37,3495,3496],{},"DB_POOL_RECYCLE",[3426,3498,3499],{},[37,3500,3501],{},"1800",[3426,3503,3504,3506],{},[37,3505,3501],{},", below the host idle cutoff",[3408,3508,3509,3514,3518],{},[3426,3510,3511],{},[37,3512,3513],{},"DB_CONNECT_TIMEOUT",[3426,3515,3516],{},[37,3517,100],{},[3426,3519,3520,3522],{},[37,3521,70],{}," seconds",[3408,3524,3525,3530,3535],{},[3426,3526,3527],{},[37,3528,3529],{},"DB_STATEMENT_TIMEOUT_MS",[3426,3531,3532],{},[37,3533,3534],{},"5000",[3426,3536,3537,3539],{},[37,3538,3534],{}," for the API role",[3408,3541,3542,3547,3551],{},[3426,3543,3544],{},[37,3545,3546],{},"DB_ECHO",[3426,3548,3549],{},[37,3550,82],{},[3426,3552,3553,3555],{},[37,3554,82],{},"; use logging, not SQL echo",[3408,3557,3558,3563,3567],{},[3426,3559,3560],{},[37,3561,3562],{},"SERVERLESS",[3426,3564,3565],{},[37,3566,82],{},[3426,3568,3569,3571,3572],{},[37,3570,592],{}," on functions, forces ",[37,3573,1303],{},[14,3575,3576,3577,3136,3579,3581],{},"The two values worth tuning per deployment are ",[37,3578,3445],{},[37,3580,3529],{},". Pool size follows the arithmetic from Step 2 and should change whenever you add replicas or workers. The statement timeout is a safety valve: it caps how long one bad query can pin a connection, and Postgres enforces it server-side, so it works even when your application is too busy to cancel the query itself. Set it once per role, not per query, and give background jobs a separate database role with a much longer timeout.",[191,3583],{},[194,3585,3587],{"id":3586},"gotchas-and-failure-modes","Gotchas and failure modes",[14,3589,3590,3594,3595,3598,3599,3601,3602,3604,3605,3607,3608,3611],{},[3591,3592,3593],"strong",{},"Lazy loading during response serialization."," Symptom: ",[37,3596,3597],{},"sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called"," raised from inside Pydantic serialization, only on endpoints that return nested data, and never in the unit tests that hit the repository layer directly. Cause: an attribute that was not eagerly loaded gets touched after the session closed. Fix: declare ",[37,3600,2033],{}," on every relationship and add the explicit ",[37,3603,2432],{},"\u002F",[37,3606,2436],{}," option to the query. If you need one lazy load deliberately, use ",[37,3609,3610],{},"await obj.awaitable_attrs.owner"," while the session is still open.",[14,3613,3614,3617,3618,3621,3622,3624,3625,3628,3629,3632],{},[3591,3615,3616],{},"A sync driver behind an async engine."," Symptom: every request fails immediately with ",[37,3619,3620],{},"InvalidRequestError: The asyncio extension requires an async driver",". Cause: ",[37,3623,3430],{}," starts with ",[37,3626,3627],{},"postgresql:\u002F\u002F"," because that is what the hosting dashboard emits. Fix: normalise the scheme in code as ",[37,3630,3631],{},"_url()"," does above, and add a startup assertion so a misconfigured environment fails at boot rather than on first traffic.",[14,3634,3635,3594,3638,3641],{},[3591,3636,3637],{},"Holding a connection across an HTTP call.",[37,3639,3640],{},"TimeoutError: QueuePool limit of size 5 overflow 5 reached"," under load that your database barely notices — CPU at 8%, connections pegged. Cause: a handler awaits Stripe or an LLM inside the request transaction. Fix: commit before the outbound call and open a second transaction to persist the result, or hand the whole thing to a background worker.",[14,3643,3644,3594,3647,3650,3651,3653,3654,3657,3658,3661],{},[3591,3645,3646],{},"Sharing one session across concurrent tasks.",[37,3648,3649],{},"InterfaceError: cannot perform operation: another operation is in progress",", or rows from one request appearing in another. Cause: passing a single ",[37,3652,1594],{}," into ",[37,3655,3656],{},"asyncio.gather()",". A session is not concurrency-safe. Fix: give each task its own session from ",[37,3659,3660],{},"SessionLocal()",", or run the queries sequentially — usually they are fast enough that concurrency buys nothing.",[14,3663,3664,3667,3668,3671],{},[3591,3665,3666],{},"Autogenerate producing a destructive migration."," Symptom: a review-less ",[37,3669,3670],{},"alembic upgrade head"," drops an index or a column that a table needs, because the model file was mid-refactor when the revision was generated. Fix: read every generated migration, run it against a restored copy of production data in CI, and never let the deploy pipeline generate migrations — it may only apply committed ones.",[191,3673],{},[194,3675,3677],{"id":3676},"verification","Verification",[14,3679,3680],{},"Prove the wiring end to end before you trust it. Add a readiness endpoint that actually touches the database and reports pool state, then hit it while the app is running:",[202,3682,3684],{"className":379,"code":3683,"language":381,"meta":207,"style":207},"from sqlalchemy import text\n\n\n@app.get(\"\u002Fhealthz\u002Fdb\")\nasync def db_health(db: DbSession) -> dict[str, object]:\n    value = await db.scalar(text(\"SELECT 1\"))\n    pool = engine.pool\n    return {\n        \"ok\": value == 1,\n        \"checked_out\": pool.checkedout(),\n        \"size\": pool.size(),\n        \"overflow\": pool.overflow(),\n    }\n",[37,3685,3686,3697,3701,3705,3717,3739,3756,3766,3772,3787,3795,3803,3811],{"__ignoreMap":207},[211,3687,3688,3690,3692,3694],{"class":126,"line":213},[211,3689,396],{"class":261},[211,3691,1715],{"class":265},[211,3693,388],{"class":261},[211,3695,3696],{"class":265}," text\n",[211,3698,3699],{"class":126,"line":275},[211,3700,468],{"emptyLinePlaceholder":467},[211,3702,3703],{"class":126,"line":288},[211,3704,468],{"emptyLinePlaceholder":467},[211,3706,3707,3710,3712,3715],{"class":126,"line":300},[211,3708,3709],{"class":216},"@app.get",[211,3711,485],{"class":265},[211,3713,3714],{"class":220},"\"\u002Fhealthz\u002Fdb\"",[211,3716,435],{"class":265},[211,3718,3719,3721,3723,3726,3729,3731,3733,3736],{"class":126,"line":313},[211,3720,1169],{"class":261},[211,3722,1172],{"class":261},[211,3724,3725],{"class":216}," db_health",[211,3727,3728],{"class":265},"(db: DbSession) -> dict[",[211,3730,528],{"class":527},[211,3732,490],{"class":265},[211,3734,3735],{"class":527},"object",[211,3737,3738],{"class":265},"]:\n",[211,3740,3741,3744,3746,3748,3751,3754],{"class":126,"line":326},[211,3742,3743],{"class":265},"    value ",[211,3745,269],{"class":261},[211,3747,1784],{"class":261},[211,3749,3750],{"class":265}," db.scalar(text(",[211,3752,3753],{"class":220},"\"SELECT 1\"",[211,3755,2361],{"class":265},[211,3757,3758,3761,3763],{"class":126,"line":339},[211,3759,3760],{"class":265},"    pool ",[211,3762,269],{"class":261},[211,3764,3765],{"class":265}," engine.pool\n",[211,3767,3768,3770],{"class":126,"line":432},[211,3769,600],{"class":261},[211,3771,683],{"class":265},[211,3773,3774,3777,3780,3782,3785],{"class":126,"line":438},[211,3775,3776],{"class":220},"        \"ok\"",[211,3778,3779],{"class":265},": value ",[211,3781,649],{"class":261},[211,3783,3784],{"class":527}," 1",[211,3786,866],{"class":265},[211,3788,3789,3792],{"class":126,"line":451},[211,3790,3791],{"class":220},"        \"checked_out\"",[211,3793,3794],{"class":265},": pool.checkedout(),\n",[211,3796,3797,3800],{"class":126,"line":464},[211,3798,3799],{"class":220},"        \"size\"",[211,3801,3802],{"class":265},": pool.size(),\n",[211,3804,3805,3808],{"class":126,"line":471},[211,3806,3807],{"class":220},"        \"overflow\"",[211,3809,3810],{"class":265},": pool.overflow(),\n",[211,3812,3813],{"class":126,"line":476},[211,3814,759],{"class":265},[202,3816,3818],{"className":204,"code":3817,"language":206,"meta":207,"style":207},"curl -s \"${API_BASE_URL}\u002Fhealthz\u002Fdb\" | python -m json.tool\n",[37,3819,3820],{"__ignoreMap":207},[211,3821,3822,3825,3828,3831,3834,3837,3840,3843,3845],{"class":126,"line":213},[211,3823,3824],{"class":216},"curl",[211,3826,3827],{"class":527}," -s",[211,3829,3830],{"class":220}," \"${",[211,3832,3833],{"class":265},"API_BASE_URL",[211,3835,3836],{"class":220},"}\u002Fhealthz\u002Fdb\"",[211,3838,3839],{"class":261}," |",[211,3841,3842],{"class":216}," python",[211,3844,2800],{"class":527},[211,3846,3847],{"class":220}," json.tool\n",[14,3849,3850,3851,3854,3855,3858,3859,3861,3862,3865],{},"Expect ",[37,3852,3853],{},"{\"ok\": true, \"checked_out\": 1, \"size\": 5, \"overflow\": -4}"," on an idle process — ",[37,3856,3857],{},"checked_out"," is 1 because the request itself holds a connection, and a negative overflow simply means the pool has not filled yet. If ",[37,3860,3857],{}," stays near ",[37,3863,3864],{},"size + max_overflow"," after traffic drains, you are leaking sessions somewhere.",[14,3867,3868,3869,3872],{},"For the query count, assert it in a test rather than eyeballing logs. A ",[37,3870,3871],{},"before_cursor_execute"," listener counts statements, and the test fails if the endpoint regresses to N+1:",[202,3874,3876],{"className":379,"code":3875,"language":381,"meta":207,"style":207},"import pytest\nfrom sqlalchemy import event\n\n\n@pytest.mark.asyncio\nasync def test_listing_is_not_n_plus_one(client, engine):\n    count = 0\n\n    def _count(conn, cursor, statement, params, ctx, many):\n        nonlocal count\n        count += 1\n\n    sync_engine = engine.sync_engine\n    event.listen(sync_engine, \"before_cursor_execute\", _count)\n    try:\n        response = await client.get(\"\u002Fv1\u002Fowners?limit=50\")\n    finally:\n        event.remove(sync_engine, \"before_cursor_execute\", _count)\n\n    assert response.status_code == 200\n    assert count \u003C= 3, f\"expected \u003C= 3 queries, issued {count}\"\n",[37,3877,3878,3885,3896,3900,3904,3909,3921,3931,3935,3946,3954,3965,3969,3979,3990,3996,4013,4020,4029,4033,4046],{"__ignoreMap":207},[211,3879,3880,3882],{"class":126,"line":213},[211,3881,388],{"class":261},[211,3883,3884],{"class":265}," pytest\n",[211,3886,3887,3889,3891,3893],{"class":126,"line":275},[211,3888,396],{"class":261},[211,3890,1715],{"class":265},[211,3892,388],{"class":261},[211,3894,3895],{"class":265}," event\n",[211,3897,3898],{"class":126,"line":288},[211,3899,468],{"emptyLinePlaceholder":467},[211,3901,3902],{"class":126,"line":300},[211,3903,468],{"emptyLinePlaceholder":467},[211,3905,3906],{"class":126,"line":313},[211,3907,3908],{"class":216},"@pytest.mark.asyncio\n",[211,3910,3911,3913,3915,3918],{"class":126,"line":326},[211,3912,1169],{"class":261},[211,3914,1172],{"class":261},[211,3916,3917],{"class":216}," test_listing_is_not_n_plus_one",[211,3919,3920],{"class":265},"(client, engine):\n",[211,3922,3923,3926,3928],{"class":126,"line":339},[211,3924,3925],{"class":265},"    count ",[211,3927,269],{"class":261},[211,3929,3930],{"class":527}," 0\n",[211,3932,3933],{"class":126,"line":432},[211,3934,468],{"emptyLinePlaceholder":467},[211,3936,3937,3940,3943],{"class":126,"line":438},[211,3938,3939],{"class":261},"    def",[211,3941,3942],{"class":216}," _count",[211,3944,3945],{"class":265},"(conn, cursor, statement, params, ctx, many):\n",[211,3947,3948,3951],{"class":126,"line":451},[211,3949,3950],{"class":261},"        nonlocal",[211,3952,3953],{"class":265}," count\n",[211,3955,3956,3959,3962],{"class":126,"line":464},[211,3957,3958],{"class":265},"        count ",[211,3960,3961],{"class":261},"+=",[211,3963,3964],{"class":527}," 1\n",[211,3966,3967],{"class":126,"line":471},[211,3968,468],{"emptyLinePlaceholder":467},[211,3970,3971,3974,3976],{"class":126,"line":476},[211,3972,3973],{"class":265},"    sync_engine ",[211,3975,269],{"class":261},[211,3977,3978],{"class":265}," engine.sync_engine\n",[211,3980,3981,3984,3987],{"class":126,"line":499},[211,3982,3983],{"class":265},"    event.listen(sync_engine, ",[211,3985,3986],{"class":220},"\"before_cursor_execute\"",[211,3988,3989],{"class":265},", _count)\n",[211,3991,3992,3994],{"class":126,"line":505},[211,3993,1935],{"class":261},[211,3995,531],{"class":265},[211,3997,3998,4001,4003,4005,4008,4011],{"class":126,"line":510},[211,3999,4000],{"class":265},"        response ",[211,4002,269],{"class":261},[211,4004,1784],{"class":261},[211,4006,4007],{"class":265}," client.get(",[211,4009,4010],{"class":220},"\"\u002Fv1\u002Fowners?limit=50\"",[211,4012,435],{"class":265},[211,4014,4015,4018],{"class":126,"line":515},[211,4016,4017],{"class":261},"    finally",[211,4019,531],{"class":265},[211,4021,4022,4025,4027],{"class":126,"line":534},[211,4023,4024],{"class":265},"        event.remove(sync_engine, ",[211,4026,3986],{"class":220},[211,4028,3989],{"class":265},[211,4030,4031],{"class":126,"line":551},[211,4032,468],{"emptyLinePlaceholder":467},[211,4034,4035,4038,4041,4043],{"class":126,"line":558},[211,4036,4037],{"class":261},"    assert",[211,4039,4040],{"class":265}," response.status_code ",[211,4042,649],{"class":261},[211,4044,4045],{"class":527}," 200\n",[211,4047,4048,4050,4053,4056,4059,4061,4064,4067,4069,4072,4075],{"class":126,"line":572},[211,4049,4037],{"class":261},[211,4051,4052],{"class":265}," count ",[211,4054,4055],{"class":261},"\u003C=",[211,4057,4058],{"class":527}," 3",[211,4060,490],{"class":265},[211,4062,4063],{"class":261},"f",[211,4065,4066],{"class":220},"\"expected \u003C= 3 queries, issued ",[211,4068,822],{"class":527},[211,4070,4071],{"class":265},"count",[211,4073,4074],{"class":527},"}",[211,4076,4077],{"class":220},"\"\n",[14,4079,4080,4081,4085],{},"That test is the highest-value one on this page; wire it into the suite described in ",[18,4082,4084],{"href":4083},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest"," and an N+1 regression can never reach a customer.",[191,4087],{},[194,4089,4091],{"id":4090},"cost-and-performance-at-scale","Cost and performance at scale",[14,4093,4094,4095,4099],{},"Database access is where an API's unit economics are decided. At a million requests a month — roughly 0.4 requests per second average, with peaks perhaps ten times that — a well-loaded endpoint issuing one or two queries at 4 ms each keeps p95 latency under 40 ms and runs comfortably on a $20–$40 managed Postgres and two small API containers. The same endpoint with an N+1 pattern issues 200 million queries a month instead of two million, which does not merely add latency: it pushes you onto a larger database plan for connection headroom and IOPS, adds a second or third API replica to absorb the latency, and turns a $60 monthly infrastructure line into $300 or more. Fixing the loading strategy is a fifteen-minute change that recovers the entire difference, and it compounds — every customer you add rides the cheaper curve. On serverless the arithmetic is harsher still, because you also pay for wall-clock execution time: 800 ms of waiting on N+1 queries is 800 ms of billed compute per request, so the same fix cuts both your database bill and your function bill at once. Before you set prices, run the numbers for your own endpoints with ",[18,4096,4098],{"href":4097},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request","; a query-count budget per endpoint is the cheapest performance guardrail you will ever add.",[191,4101],{},[194,4103,4105],{"id":4104},"faq","FAQ",[14,4107,4108,4111],{},[3591,4109,4110],{},"How much does a Postgres-backed API cost to run at a million requests a month?","\nAround $60 a month all in if you keep query counts low: roughly $25 for a small managed Postgres, $15–$30 for two API containers, and near-zero egress for JSON. The variable that moves the number is queries per request, not requests per second — an N+1 listing endpoint can multiply the database line by five without any change in traffic.",[14,4113,4114,4117,4118,4120],{},[3591,4115,4116],{},"Should I use an external pooler like PgBouncer, or is the SQLAlchemy pool enough?","\nOn long-lived containers with a handful of processes, the SQLAlchemy pool is enough and adds one less moving part. Add PgBouncer in transaction mode the moment you go serverless, exceed roughly 50 total processes, or start seeing connection-limit errors during deploys when old and new instances overlap. Remember to set ",[37,4119,1380],{}," when you do.",[14,4122,4123,4126,4127,4129,4130,4133],{},[3591,4124,4125],{},"What is the migration risk when I change a table that customers depend on?","\nThe risk is lock duration, not the schema change itself. A migration that holds ",[37,4128,3158],{}," on a large table queues every query behind it and reads to your customers as a full outage. Set ",[37,4131,4132],{},"lock_timeout",", build indexes concurrently, and split anything semantic into expand-and-contract steps so no single release requires the code and schema to flip together.",[14,4135,4136,4139],{},[3591,4137,4138],{},"Do I need async at all, or would sync SQLAlchemy with more workers be cheaper?","\nIf your endpoints do one fast query and return, sync plus more worker processes is genuinely simpler and costs about the same. Async pays for itself when a request waits on several I\u002FO operations — a database read plus two upstream API calls — because one worker handles hundreds of concurrent waits instead of one. Most commercial APIs cross that line quickly, which is why the async stack is the better default.",[14,4141,4142,4145,4146,1394],{},[3591,4143,4144],{},"How do I keep read-heavy analytics queries from starving my API pool?","\nGive them their own database role, their own engine, and their own pool — ideally pointed at a read replica. Analytics scans hold connections for seconds; API queries hold them for milliseconds. Mixing them in one pool means one dashboard refresh can time out live traffic. The same separation applies to the writes behind ",[18,4147,4149],{"href":4148},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","logging API usage events to Postgres",[191,4151],{},[194,4153,4155],{"id":4154},"related","Related",[14,4157,4158],{},[3591,4159,4160],{},"Same track:",[4162,4163,4164,4170,4180,4186],"ul",{},[4165,4166,4167,4169],"li",{},[18,4168,34],{"href":33}," — pick the driver before you tune anything else.",[4165,4171,4172,4175,4176,4179],{},[18,4173,4174],{"href":1392},"Fixing Connection Pool Exhaustion"," — the diagnosis playbook when ",[37,4177,4178],{},"QueuePool limit"," errors start firing.",[4165,4181,4182,4185],{},[18,4183,4184],{"href":2746},"Caching Python API Responses with Redis"," — stop running the query at all for hot, slow-changing reads.",[4165,4187,4188,4191],{},[18,4189,4190],{"href":4083},"Testing Python APIs with pytest"," — where the query-count assertion above belongs.",[14,4193,4194],{},[3591,4195,4196],{},"Other tracks:",[4162,4198,4199],{},[4165,4200,4201,4204],{},[18,4202,4203],{"href":3299},"Zero-Downtime Deploys for Python APIs"," — how migrations and rollouts stay in step.",[4206,4207,4208],"style",{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}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);}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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":207,"searchDepth":275,"depth":275,"links":4210},[4211,4212,4213,4214,4215,4216,4217,4218,4219,4220,4221,4222],{"id":196,"depth":275,"text":197},{"id":361,"depth":275,"text":362},{"id":1222,"depth":275,"text":1223},{"id":1399,"depth":275,"text":1400},{"id":2016,"depth":275,"text":2017},{"id":2753,"depth":275,"text":2754},{"id":3399,"depth":275,"text":3400},{"id":3586,"depth":275,"text":3587},{"id":3676,"depth":275,"text":3677},{"id":4090,"depth":275,"text":4091},{"id":4104,"depth":275,"text":4105},{"id":4154,"depth":275,"text":4155},"Wire async SQLAlchemy 2.0 to Postgres from FastAPI: engine lifecycle, pool sizing for serverless vs long-lived hosts, per-request transactions, N+1, Alembic.","md",{"pageTitle":4226,"type":4227,"datePublished":4228,"dateModified":4228},"Async SQLAlchemy 2.0 with FastAPI and Postgres in Production","guide","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy",{"title":5,"description":4223},"scaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Findex","wWVhUNURurYTPpJzw--0sto9MSheTYVJ9QeQ3J4PvjM",{"@context":4234,"@type":4235,"mainEntity":4236},"https:\u002F\u002Fschema.org","FAQPage",[4237,4242,4245,4248,4251],{"@type":4238,"name":4110,"acceptedAnswer":4239},"Question",{"@type":4240,"text":4241},"Answer","Around $60 a month all in if you keep query counts low: roughly $25 for a small managed Postgres, $15–$30 for two API containers, and near-zero egress for JSON. The variable that moves the number is queries per request, not requests per second — an N+1 listing endpoint can multiply the database line by five without any change in traffic.",{"@type":4238,"name":4116,"acceptedAnswer":4243},{"@type":4240,"text":4244},"On long-lived containers with a handful of processes, the SQLAlchemy pool is enough and adds one less moving part. Add PgBouncer in transaction mode the moment you go serverless, exceed roughly 50 total processes, or start seeing connection-limit errors during deploys when old and new instances overlap. Remember to set statement_cache_size=0 when you do.",{"@type":4238,"name":4125,"acceptedAnswer":4246},{"@type":4240,"text":4247},"The risk is lock duration, not the schema change itself. A migration that holds ACCESS EXCLUSIVE on a large table queues every query behind it and reads to your customers as a full outage. Set lock_timeout, build indexes concurrently, and split anything semantic into expand-and-contract steps so no single release requires the code and schema to flip together.",{"@type":4238,"name":4138,"acceptedAnswer":4249},{"@type":4240,"text":4250},"If your endpoints do one fast query and return, sync plus more worker processes is genuinely simpler and costs about the same. Async pays for itself when a request waits on several I\u002FO operations — a database read plus two upstream API calls — because one worker handles hundreds of concurrent waits instead of one. Most commercial APIs cross that line quickly, which is why the async stack is the better default.",{"@type":4238,"name":4144,"acceptedAnswer":4252},{"@type":4240,"text":4253},"Give them their own database role, their own engine, and their own pool — ideally pointed at a read replica. Analytics scans hold connections for seconds; API queries hold them for milliseconds. Mixing them in one pool means one dashboard refresh can time out live traffic. The same separation applies to the writes behind logging API usage events to Postgres. ---",1784887027767]