[{"data":1,"prerenderedAt":2031},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002F":2010},{"id":4,"title":5,"body":6,"description":2000,"extension":2001,"meta":2002,"navigation":229,"path":2006,"seo":2007,"stem":2008,"__hash__":2009},"content\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002Findex.md","asyncpg vs psycopg3: Picking the Right Async Postgres Driver for FastAPI",{"type":7,"value":8,"toc":1989},"minimark",[9,13,36,42,47,52,65,68,186,189,494,504,508,513,520,679,687,693,697,705,710,831,849,1187,1195,1199,1220,1236,1422,1552,1563,1579,1583,1656,1757,1765,1769,1772,1864,1867,1871,1878,1884,1888,1894,1903,1916,1922,1928,1932,1937,1965,1970,1985],[10,11,5],"h1",{"id":12},"asyncpg-vs-psycopg3-picking-the-right-async-postgres-driver-for-fastapi",[14,15,16,17,21,22,25,26,29,30,35],"p",{},"Two drivers can sit under async SQLAlchemy and talk to Postgres: ",[18,19,20],"code",{},"asyncpg"," and ",[18,23,24],{},"psycopg"," version 3. Both work. Both are maintained. The SQLAlchemy code above them is byte-for-byte identical, so the choice feels cosmetic — right up until the afternoon your API starts throwing ",[18,27,28],{},"prepared statement \"__asyncpg_stmt_3__\" does not exist"," at ten percent of requests because your managed Postgres put a transaction pooler in front of the database. This page resolves that choice before it costs you a weekend. Part of the ",[31,32,34],"a",{"href":33},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","Async Database Access with SQLAlchemy"," guide.",[14,37,38,39,41],{},"The short version: raw speed favours ",[18,40,20],{},", but raw speed is almost never the constraint on a commercial API. Compatibility with your hosting model is, and that is where the two drivers genuinely diverge.",[43,44,46],"h2",{"id":45},"how-each-driver-actually-reaches-postgres","How each driver actually reaches Postgres",[14,48,49,51],{},[18,50,20],{}," is a from-scratch implementation of the Postgres wire protocol written in Cython. It does not link against libpq at all. It speaks the extended query protocol directly, requests binary result formats wherever it can, and keeps a per-connection cache of prepared statements so a repeated query skips the parse and plan phase. That design is why it wins benchmarks: fewer format conversions, no C library boundary to marshal across, and a hot path that stays in compiled code.",[14,53,54,56,57,60,61,64],{},[18,55,24],{}," 3 is the successor to the ",[18,58,59],{},"psycopg2"," that half the Python world already runs. It keeps libpq underneath (with a pure-Python fallback), but the async support is native rather than bolted on — ",[18,62,63],{},"AsyncConnection"," is a real coroutine API, not a thread pool wearing a costume. It uses server-side parameter binding by default, supports pipeline mode to cut round trips, and prepares statements automatically only after it has seen the same query five times.",[14,66,67],{},"Under SQLAlchemy 2.0 you never touch either API. You pick a dialect in the URL and everything above it stays the same.",[69,70,78,79,78,83,78,87,78,94,78,102,78,110,78,115,78,121,78,126,78,130,78,134,78,137,78,141,78,143,78,146,78,149,78,153,78,155,78,158,78,161,78,165,78,169,78,171,78,174,78,176,78,179,78,181,78,184],"svg",{"viewBox":71,"role":72,"ariaLabelledBy":73,"xmlns":76,"style":77},"0 0 760 348","img",[74,75],"apg3-stack-t","apg3-stack-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[80,81,82],"title",{"id":74},"Driver stacks compared",[84,85,86],"desc",{"id":75},"Both drivers sit under the same SQLAlchemy AsyncSession, but asyncpg implements the Postgres protocol itself in Cython while psycopg 3 wraps libpq.",[88,89],"rect",{"x":90,"y":90,"width":91,"height":92,"fill":93},"0","760","348","var(--c-surface)",[95,96,101],"text",{"x":97,"y":98,"fill":99,"style":100},"380","24","var(--c-text)","text-anchor:middle;font-size:14;font-family:var(--font-sans);","Same code on top, different wire implementation",[88,103],{"x":104,"y":104,"width":105,"height":104,"rx":106,"fill":107,"stroke":108,"style":109},"40","680","8","var(--c-surface-alt)","var(--c-border)","stroke-width:2;",[95,111,114],{"x":97,"y":112,"fill":99,"style":113},"65","text-anchor:middle;font-size:13;font-family:var(--font-sans);","SQLAlchemy 2.0 AsyncSession",[88,116],{"x":104,"y":117,"width":118,"height":119,"rx":106,"fill":93,"stroke":120,"style":109},"104","300","44","var(--c-teal)",[95,122,125],{"x":123,"y":124,"fill":99,"style":113},"190","131","postgresql+asyncpg",[88,127],{"x":128,"y":117,"width":118,"height":119,"rx":106,"fill":93,"stroke":129,"style":109},"420","var(--c-coral)",[95,131,133],{"x":132,"y":124,"fill":99,"style":113},"570","postgresql+psycopg",[88,135],{"x":104,"y":136,"width":118,"height":119,"rx":106,"fill":93,"stroke":120,"style":109},"164",[95,138,140],{"x":123,"y":139,"fill":99,"style":113},"191","Own protocol, Cython",[88,142],{"x":128,"y":136,"width":118,"height":119,"rx":106,"fill":93,"stroke":129,"style":109},[95,144,145],{"x":132,"y":139,"fill":99,"style":113},"libpq, or pure Python",[88,147],{"x":104,"y":148,"width":118,"height":119,"rx":106,"fill":93,"stroke":120,"style":109},"224",[95,150,152],{"x":123,"y":151,"fill":99,"style":113},"251","Binary, statements cached",[88,154],{"x":128,"y":148,"width":118,"height":119,"rx":106,"fill":93,"stroke":129,"style":109},[95,156,157],{"x":132,"y":151,"fill":99,"style":113},"Text or binary, pipeline",[88,159],{"x":104,"y":160,"width":105,"height":104,"rx":106,"fill":107,"stroke":108,"style":109},"288",[95,162,164],{"x":97,"y":163,"fill":99,"style":113},"313","PostgreSQL server",[166,167],"line",{"x1":123,"y1":168,"x2":123,"y2":117,"stroke":108,"style":109},"80",[166,170],{"x1":132,"y1":168,"x2":132,"y2":117,"stroke":108,"style":109},[166,172],{"x1":123,"y1":173,"x2":123,"y2":136,"stroke":108,"style":109},"148",[166,175],{"x1":132,"y1":173,"x2":132,"y2":136,"stroke":108,"style":109},[166,177],{"x1":123,"y1":178,"x2":123,"y2":148,"stroke":108,"style":109},"208",[166,180],{"x1":132,"y1":178,"x2":132,"y2":148,"stroke":108,"style":109},[166,182],{"x1":123,"y1":183,"x2":123,"y2":160,"stroke":108,"style":109},"268",[166,185],{"x1":132,"y1":183,"x2":132,"y2":160,"stroke":108,"style":109},[14,187,188],{},"Switching between them is one string. Drive it from the environment so staging can prove a swap before production sees it:",[190,191,196],"pre",{"className":192,"code":193,"language":194,"meta":195,"style":195},"language-python shiki shiki-themes github-light github-dark","import os\nfrom sqlalchemy.ext.asyncio import create_async_engine\n\ndef build_url() -> str:\n    \"\"\"Turn a plain DATABASE_URL into a driver-specific SQLAlchemy URL.\"\"\"\n    dsn = os.environ[\"DATABASE_URL\"]\n    match os.getenv(\"DB_DRIVER\", \"asyncpg\"):\n        case \"asyncpg\":\n            return dsn.replace(\"postgresql:\u002F\u002F\", \"postgresql+asyncpg:\u002F\u002F\", 1)\n        case \"psycopg\":\n            return dsn.replace(\"postgresql:\u002F\u002F\", \"postgresql+psycopg:\u002F\u002F\", 1)\n        case unknown:\n            raise RuntimeError(f\"unsupported DB_DRIVER: {unknown!r}\")\n\nengine = create_async_engine(\n    build_url(),\n    pool_size=int(os.getenv(\"DB_POOL_SIZE\", \"5\")),\n    max_overflow=int(os.getenv(\"DB_MAX_OVERFLOW\", \"5\")),\n    pool_pre_ping=True,\n)\n","python","",[18,197,198,210,224,231,251,258,276,297,308,333,343,363,371,406,411,422,428,454,475,489],{"__ignoreMap":195},[199,200,202,206],"span",{"class":166,"line":201},1,[199,203,205],{"class":204},"szBVR","import",[199,207,209],{"class":208},"sVt8B"," os\n",[199,211,213,216,219,221],{"class":166,"line":212},2,[199,214,215],{"class":204},"from",[199,217,218],{"class":208}," sqlalchemy.ext.asyncio ",[199,220,205],{"class":204},[199,222,223],{"class":208}," create_async_engine\n",[199,225,227],{"class":166,"line":226},3,[199,228,230],{"emptyLinePlaceholder":229},true,"\n",[199,232,234,237,241,244,248],{"class":166,"line":233},4,[199,235,236],{"class":204},"def",[199,238,240],{"class":239},"sScJk"," build_url",[199,242,243],{"class":208},"() -> ",[199,245,247],{"class":246},"sj4cs","str",[199,249,250],{"class":208},":\n",[199,252,254],{"class":166,"line":253},5,[199,255,257],{"class":256},"sZZnC","    \"\"\"Turn a plain DATABASE_URL into a driver-specific SQLAlchemy URL.\"\"\"\n",[199,259,261,264,267,270,273],{"class":166,"line":260},6,[199,262,263],{"class":208},"    dsn ",[199,265,266],{"class":204},"=",[199,268,269],{"class":208}," os.environ[",[199,271,272],{"class":256},"\"DATABASE_URL\"",[199,274,275],{"class":208},"]\n",[199,277,279,282,285,288,291,294],{"class":166,"line":278},7,[199,280,281],{"class":204},"    match",[199,283,284],{"class":208}," os.getenv(",[199,286,287],{"class":256},"\"DB_DRIVER\"",[199,289,290],{"class":208},", ",[199,292,293],{"class":256},"\"asyncpg\"",[199,295,296],{"class":208},"):\n",[199,298,300,303,306],{"class":166,"line":299},8,[199,301,302],{"class":204},"        case",[199,304,305],{"class":256}," \"asyncpg\"",[199,307,250],{"class":208},[199,309,311,314,317,320,322,325,327,330],{"class":166,"line":310},9,[199,312,313],{"class":204},"            return",[199,315,316],{"class":208}," dsn.replace(",[199,318,319],{"class":256},"\"postgresql:\u002F\u002F\"",[199,321,290],{"class":208},[199,323,324],{"class":256},"\"postgresql+asyncpg:\u002F\u002F\"",[199,326,290],{"class":208},[199,328,329],{"class":246},"1",[199,331,332],{"class":208},")\n",[199,334,336,338,341],{"class":166,"line":335},10,[199,337,302],{"class":204},[199,339,340],{"class":256}," \"psycopg\"",[199,342,250],{"class":208},[199,344,346,348,350,352,354,357,359,361],{"class":166,"line":345},11,[199,347,313],{"class":204},[199,349,316],{"class":208},[199,351,319],{"class":256},[199,353,290],{"class":208},[199,355,356],{"class":256},"\"postgresql+psycopg:\u002F\u002F\"",[199,358,290],{"class":208},[199,360,329],{"class":246},[199,362,332],{"class":208},[199,364,366,368],{"class":166,"line":365},12,[199,367,302],{"class":204},[199,369,370],{"class":208}," unknown:\n",[199,372,374,377,380,383,386,389,392,395,398,401,404],{"class":166,"line":373},13,[199,375,376],{"class":204},"            raise",[199,378,379],{"class":246}," RuntimeError",[199,381,382],{"class":208},"(",[199,384,385],{"class":204},"f",[199,387,388],{"class":256},"\"unsupported DB_DRIVER: ",[199,390,391],{"class":246},"{",[199,393,394],{"class":208},"unknown",[199,396,397],{"class":204},"!r",[199,399,400],{"class":246},"}",[199,402,403],{"class":256},"\"",[199,405,332],{"class":208},[199,407,409],{"class":166,"line":408},14,[199,410,230],{"emptyLinePlaceholder":229},[199,412,414,417,419],{"class":166,"line":413},15,[199,415,416],{"class":208},"engine ",[199,418,266],{"class":204},[199,420,421],{"class":208}," create_async_engine(\n",[199,423,425],{"class":166,"line":424},16,[199,426,427],{"class":208},"    build_url(),\n",[199,429,431,435,437,440,443,446,448,451],{"class":166,"line":430},17,[199,432,434],{"class":433},"s4XuR","    pool_size",[199,436,266],{"class":204},[199,438,439],{"class":246},"int",[199,441,442],{"class":208},"(os.getenv(",[199,444,445],{"class":256},"\"DB_POOL_SIZE\"",[199,447,290],{"class":208},[199,449,450],{"class":256},"\"5\"",[199,452,453],{"class":208},")),\n",[199,455,457,460,462,464,466,469,471,473],{"class":166,"line":456},18,[199,458,459],{"class":433},"    max_overflow",[199,461,266],{"class":204},[199,463,439],{"class":246},[199,465,442],{"class":208},[199,467,468],{"class":256},"\"DB_MAX_OVERFLOW\"",[199,470,290],{"class":208},[199,472,450],{"class":256},[199,474,453],{"class":208},[199,476,478,481,483,486],{"class":166,"line":477},19,[199,479,480],{"class":433},"    pool_pre_ping",[199,482,266],{"class":204},[199,484,485],{"class":246},"True",[199,487,488],{"class":208},",\n",[199,490,492],{"class":166,"line":491},20,[199,493,332],{"class":208},[14,495,496,497,499,500,503],{},"Note the dialect name for psycopg 3 is ",[18,498,24],{},", not ",[18,501,502],{},"psycopg3",". That trips up everyone once.",[43,505,507],{"id":506},"throughput-and-latency-where-the-gap-disappears","Throughput and latency: where the gap disappears",[14,509,510,512],{},[18,511,20],{}," is genuinely faster on the wire, and the benchmarks you find online are not lying. They are just measuring a layer you do not ship. Once SQLAlchemy Core compiles the statement and the ORM hydrates rows into model instances, Python object construction dominates and the driver's share of the cost shrinks toward noise.",[14,514,515,516,519],{},"The shape below is what you should expect from a simple indexed ",[18,517,518],{},"SELECT"," returning twenty rows against a Postgres on the same private network. Treat the absolute numbers as illustrative of the ratio, not as a promise for your hardware — the point is how the gap narrows as you add layers.",[69,521,78,526,78,529,78,532,78,535,78,540,78,546,78,551,78,554,78,557,78,562,78,565,78,569,78,572,78,575,78,577,78,582,78,586,78,589,78,593,78,597,78,603,78,608,78,614,78,619,78,623,78,627,78,630,78,634,78,639,78,644,78,649,78,653,78,657,78,661,78,666,78,669,78,672,78,676],{"viewBox":522,"role":72,"ariaLabelledBy":523,"xmlns":76,"style":77},"0 0 760 350",[524,525],"apg3-bench-t","apg3-bench-d",[80,527,528],{"id":524},"Median query latency by layer",[84,530,531],{"id":525},"Grouped bars showing p50 microseconds per query for asyncpg and psycopg 3 at the raw driver, SQLAlchemy Core, and ORM layers; the gap falls from about 45 percent to under 10 percent.",[88,533],{"x":90,"y":90,"width":91,"height":534,"fill":93},"350",[95,536,539],{"x":537,"y":538,"fill":99,"style":100},"395","22","p50 microseconds per query, 20-row SELECT",[88,541],{"x":542,"y":543,"width":544,"height":544,"rx":545,"fill":120},"250","36","14","3",[95,547,20],{"x":548,"y":549,"fill":99,"style":550},"272","48","font-size:12;font-family:var(--font-sans);",[88,552],{"x":553,"y":543,"width":544,"height":544,"rx":545,"fill":129},"400",[95,555,502],{"x":556,"y":549,"fill":99,"style":550},"422",[166,558],{"x1":559,"y1":560,"x2":559,"y2":561,"stroke":108,"style":109},"90","70","280",[166,563],{"x1":559,"y1":561,"x2":564,"y2":561,"stroke":108,"style":109},"700",[166,566],{"x1":559,"y1":567,"x2":564,"y2":567,"stroke":108,"style":568},"228","stroke-width:1;stroke-dasharray:4 4;",[166,570],{"x1":559,"y1":571,"x2":564,"y2":571,"stroke":108,"style":568},"175",[166,573],{"x1":559,"y1":574,"x2":564,"y2":574,"stroke":108,"style":568},"123",[166,576],{"x1":559,"y1":560,"x2":564,"y2":560,"stroke":108,"style":568},[95,578,90],{"x":168,"y":579,"fill":580,"style":581},"284","var(--c-text-muted)","text-anchor:end;font-size:11;font-family:var(--font-sans);",[95,583,585],{"x":168,"y":584,"fill":580,"style":581},"232","200",[95,587,553],{"x":168,"y":588,"fill":580,"style":581},"179",[95,590,592],{"x":168,"y":591,"fill":580,"style":581},"127","600",[95,594,596],{"x":168,"y":595,"fill":580,"style":581},"74","800",[95,598,602],{"x":599,"y":571,"fill":580,"transform":600,"style":601},"34","rotate(-90 34 175)","text-anchor:middle;font-size:12;font-family:var(--font-sans);","microseconds",[88,604],{"x":605,"y":151,"width":606,"height":607,"fill":120},"130","46","29",[95,609,613],{"x":610,"y":611,"fill":99,"style":612},"153","245","text-anchor:middle;font-size:10;font-family:var(--font-sans);","110",[88,615],{"x":616,"y":617,"width":606,"height":618,"fill":129},"184","238","42",[95,620,622],{"x":621,"y":584,"fill":99,"style":612},"207","160",[88,624],{"x":625,"y":626,"width":606,"height":595,"fill":120},"330","206",[95,628,561],{"x":629,"y":585,"fill":99,"style":612},"353",[88,631],{"x":632,"y":139,"width":606,"height":633,"fill":129},"384","89",[95,635,638],{"x":636,"y":637,"fill":99,"style":612},"407","185","340",[88,640],{"x":641,"y":642,"width":606,"height":643,"fill":120},"530","117","163",[95,645,648],{"x":646,"y":647,"fill":99,"style":612},"553","111","620",[88,650],{"x":651,"y":652,"width":606,"height":588,"fill":129},"584","101",[95,654,105],{"x":655,"y":656,"fill":99,"style":612},"607","95",[95,658,660],{"x":659,"y":118,"fill":99,"style":601},"180","Raw driver",[95,662,665],{"x":659,"y":663,"fill":580,"style":664},"318","text-anchor:middle;font-size:11;font-family:var(--font-sans);","gap 45%",[95,667,668],{"x":97,"y":118,"fill":99,"style":601},"SQLAlchemy Core",[95,670,671],{"x":97,"y":663,"fill":580,"style":664},"gap 21%",[95,673,675],{"x":674,"y":118,"fill":99,"style":601},"580","ORM objects",[95,677,678],{"x":674,"y":663,"fill":580,"style":664},"gap 10%",[14,680,681,682,686],{},"Now price it. Suppose a request runs three queries and you serve a million requests a month. A sixty-microsecond per-query delta at the ORM layer costs 180 microseconds of CPU per request, or about three minutes of compute across the whole month. On a container that costs you a dollar or two of margin. Meanwhile a single unindexed lookup or one N+1 loop will cost you a hundred times that. If you are still sizing infrastructure spend, work through ",[31,683,685],{"href":684},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002Fcalculating-cost-per-api-request\u002F","calculating cost per API request"," before you optimise a driver.",[14,688,689,690,692],{},"Where ",[18,691,20],{}," does earn its keep is bulk work: streaming a hundred thousand rows into a report, or a query returning wide numeric columns where binary decoding beats text parsing outright. If your product has an export endpoint or an analytics job, that 45 percent at the driver layer becomes real seconds.",[43,694,696],{"id":695},"prepared-statements-and-poolers-the-actual-decision","Prepared statements and poolers: the actual decision",[14,698,699,700,704],{},"Here is the failure that decides the argument for most builders. Managed Postgres platforms put a connection pooler in front of the database — Supabase, Neon, and most PgBouncer setups run ",[701,702,703],"strong",{},"transaction pooling mode",", which hands your client a different backend connection for every transaction. A prepared statement lives on one backend. Prepare it in transaction one, execute it in transaction two, and you land on a different backend that has never heard of it.",[14,706,707,709],{},[18,708,20],{}," caches prepared statements by default, so it walks straight into this. The symptom is intermittent, which is the worst kind: it only fires when the pooler reassigns you, so it passes local testing, passes staging with one connection, and breaks under production concurrency.",[69,711,78,716,78,719,78,722,78,724,78,747,78,754,78,759,78,762,78,765,78,768,78,772,78,776,78,778,78,780,78,785,78,789,78,793,78,798,78,802,78,805,78,808,78,812,78,815,78,819,78,822,78,827],{"viewBox":712,"role":72,"ariaLabelledBy":713,"xmlns":76,"style":77},"0 0 760 340",[714,715],"apg3-pgb-t","apg3-pgb-d",[80,717,718],{"id":714},"Why prepared statements break in transaction pooling",[84,720,721],{"id":715},"A sequence showing a statement prepared on backend A and then executed after the pooler reassigns the client to backend B, which raises a does-not-exist error.",[88,723],{"x":90,"y":90,"width":91,"height":638,"fill":93},[725,726,727,728,727,742,78],"defs",{},"\n    ",[729,730,737,738,727],"marker",{"id":731,"viewBox":732,"refX":733,"refY":734,"markerWidth":735,"markerHeight":735,"orient":736},"apg3-pgb-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[739,740],"path",{"d":741,"fill":580},"M0 0 L10 5 L0 10 z",[729,743,737,745,727],{"id":744,"viewBox":732,"refX":733,"refY":734,"markerWidth":735,"markerHeight":735,"orient":736},"apg3-pgb-warn",[739,746],{"d":741,"fill":129},[88,748],{"x":749,"y":750,"width":751,"height":752,"rx":106,"fill":107,"stroke":753,"style":109},"30","26","170","38","var(--c-blue)",[95,755,758],{"x":756,"y":757,"fill":99,"style":601},"115","50","FastAPI worker",[88,760],{"x":761,"y":750,"width":751,"height":752,"rx":106,"fill":107,"stroke":753,"style":109},"295",[95,763,764],{"x":97,"y":757,"fill":99,"style":601},"PgBouncer",[88,766],{"x":767,"y":750,"width":751,"height":752,"rx":106,"fill":107,"stroke":753,"style":109},"560",[95,769,771],{"x":770,"y":757,"fill":99,"style":601},"645","Postgres backends",[166,773],{"x1":756,"y1":774,"x2":756,"y2":118,"stroke":108,"style":775},"64","stroke-width:1;stroke-dasharray:5 5;",[166,777],{"x1":97,"y1":774,"x2":97,"y2":118,"stroke":108,"style":775},[166,779],{"x1":770,"y1":774,"x2":770,"y2":118,"stroke":108,"style":775},[166,781],{"x1":756,"y1":782,"x2":783,"y2":782,"stroke":580,"markerEnd":784,"style":109},"100","374","url(#apg3-pgb-arrow)",[95,786,788],{"x":611,"y":787,"fill":99,"style":664},"92","PARSE stmt_7  (txn 1)",[166,790],{"x1":97,"y1":791,"x2":792,"y2":791,"stroke":580,"markerEnd":784,"style":109},"136","639",[95,794,797],{"x":795,"y":796,"fill":99,"style":664},"510","128","routed to backend A",[88,799],{"x":767,"y":800,"width":751,"height":750,"rx":801,"fill":93,"stroke":120,"style":109},"146","6",[95,803,804],{"x":770,"y":136,"fill":99,"style":664},"A holds stmt_7",[166,806],{"x1":756,"y1":807,"x2":783,"y2":807,"stroke":580,"markerEnd":784,"style":109},"204",[95,809,811],{"x":611,"y":810,"fill":99,"style":664},"196","EXECUTE stmt_7  (txn 2)",[166,813],{"x1":97,"y1":617,"x2":792,"y2":617,"stroke":129,"markerEnd":814,"style":109},"url(#apg3-pgb-warn)",[95,816,818],{"x":795,"y":817,"fill":99,"style":664},"230","routed to backend B",[166,820],{"x1":770,"y1":548,"x2":821,"y2":548,"stroke":129,"markerEnd":814,"style":109},"121",[95,823,826],{"x":824,"y":825,"fill":99,"style":664},"383","264","ERROR: prepared statement does not exist",[95,828,830],{"x":97,"y":829,"fill":580,"style":664},"316","Fix: disable statement caching, or give every statement a unique name",[14,832,833,834,836,837,840,841,843,844,848],{},"Both drivers can be told to stop. ",[18,835,24],{}," 3 takes one keyword — ",[18,838,839],{},"prepare_threshold=None"," — and never prepares anything. ",[18,842,20],{}," needs its statement cache zeroed ",[845,846,847],"em",{},"and",", because SQLAlchemy still emits a named statement per execution, a name generator that never repeats across backends:",[190,850,852],{"className":192,"code":851,"language":194,"meta":195,"style":195},"import os\nfrom uuid import uuid4\nfrom sqlalchemy.ext.asyncio import create_async_engine\nfrom sqlalchemy.pool import NullPool\n\nBEHIND_POOLER = os.getenv(\"DB_BEHIND_POOLER\", \"false\").lower() == \"true\"\nDRIVER = os.getenv(\"DB_DRIVER\", \"asyncpg\")\n\ndef pooler_kwargs() -> dict:\n    if not BEHIND_POOLER:\n        return {}\n    match DRIVER:\n        case \"psycopg\":\n            return {\"connect_args\": {\"prepare_threshold\": None}}\n        case \"asyncpg\":\n            return {\n                \"connect_args\": {\n                    \"statement_cache_size\": 0,\n                    \"prepared_statement_name_func\": lambda: f\"__api_{uuid4()}__\",\n                }\n            }\n        case _:\n            return {}\n\nengine = create_async_engine(\n    os.environ[\"SQLALCHEMY_URL\"],\n    poolclass=NullPool if os.getenv(\"DB_SERVERLESS\") == \"1\" else None,\n    **pooler_kwargs(),\n)\n",[18,853,854,860,872,882,894,898,925,942,946,960,973,981,990,998,1023,1031,1038,1046,1057,1086,1091,1097,1105,1112,1117,1126,1138,1173,1182],{"__ignoreMap":195},[199,855,856,858],{"class":166,"line":201},[199,857,205],{"class":204},[199,859,209],{"class":208},[199,861,862,864,867,869],{"class":166,"line":212},[199,863,215],{"class":204},[199,865,866],{"class":208}," uuid ",[199,868,205],{"class":204},[199,870,871],{"class":208}," uuid4\n",[199,873,874,876,878,880],{"class":166,"line":226},[199,875,215],{"class":204},[199,877,218],{"class":208},[199,879,205],{"class":204},[199,881,223],{"class":208},[199,883,884,886,889,891],{"class":166,"line":233},[199,885,215],{"class":204},[199,887,888],{"class":208}," sqlalchemy.pool ",[199,890,205],{"class":204},[199,892,893],{"class":208}," NullPool\n",[199,895,896],{"class":166,"line":253},[199,897,230],{"emptyLinePlaceholder":229},[199,899,900,903,906,908,911,913,916,919,922],{"class":166,"line":260},[199,901,902],{"class":246},"BEHIND_POOLER",[199,904,905],{"class":204}," =",[199,907,284],{"class":208},[199,909,910],{"class":256},"\"DB_BEHIND_POOLER\"",[199,912,290],{"class":208},[199,914,915],{"class":256},"\"false\"",[199,917,918],{"class":208},").lower() ",[199,920,921],{"class":204},"==",[199,923,924],{"class":256}," \"true\"\n",[199,926,927,930,932,934,936,938,940],{"class":166,"line":278},[199,928,929],{"class":246},"DRIVER",[199,931,905],{"class":204},[199,933,284],{"class":208},[199,935,287],{"class":256},[199,937,290],{"class":208},[199,939,293],{"class":256},[199,941,332],{"class":208},[199,943,944],{"class":166,"line":299},[199,945,230],{"emptyLinePlaceholder":229},[199,947,948,950,953,955,958],{"class":166,"line":310},[199,949,236],{"class":204},[199,951,952],{"class":239}," pooler_kwargs",[199,954,243],{"class":208},[199,956,957],{"class":246},"dict",[199,959,250],{"class":208},[199,961,962,965,968,971],{"class":166,"line":335},[199,963,964],{"class":204},"    if",[199,966,967],{"class":204}," not",[199,969,970],{"class":246}," BEHIND_POOLER",[199,972,250],{"class":208},[199,974,975,978],{"class":166,"line":345},[199,976,977],{"class":204},"        return",[199,979,980],{"class":208}," {}\n",[199,982,983,985,988],{"class":166,"line":365},[199,984,281],{"class":204},[199,986,987],{"class":246}," DRIVER",[199,989,250],{"class":208},[199,991,992,994,996],{"class":166,"line":373},[199,993,302],{"class":204},[199,995,340],{"class":256},[199,997,250],{"class":208},[199,999,1000,1002,1005,1008,1011,1014,1017,1020],{"class":166,"line":408},[199,1001,313],{"class":204},[199,1003,1004],{"class":208}," {",[199,1006,1007],{"class":256},"\"connect_args\"",[199,1009,1010],{"class":208},": {",[199,1012,1013],{"class":256},"\"prepare_threshold\"",[199,1015,1016],{"class":208},": ",[199,1018,1019],{"class":246},"None",[199,1021,1022],{"class":208},"}}\n",[199,1024,1025,1027,1029],{"class":166,"line":413},[199,1026,302],{"class":204},[199,1028,305],{"class":256},[199,1030,250],{"class":208},[199,1032,1033,1035],{"class":166,"line":424},[199,1034,313],{"class":204},[199,1036,1037],{"class":208}," {\n",[199,1039,1040,1043],{"class":166,"line":430},[199,1041,1042],{"class":256},"                \"connect_args\"",[199,1044,1045],{"class":208},": {\n",[199,1047,1048,1051,1053,1055],{"class":166,"line":456},[199,1049,1050],{"class":256},"                    \"statement_cache_size\"",[199,1052,1016],{"class":208},[199,1054,90],{"class":246},[199,1056,488],{"class":208},[199,1058,1059,1062,1064,1067,1069,1071,1074,1076,1079,1081,1084],{"class":166,"line":477},[199,1060,1061],{"class":256},"                    \"prepared_statement_name_func\"",[199,1063,1016],{"class":208},[199,1065,1066],{"class":204},"lambda",[199,1068,1016],{"class":208},[199,1070,385],{"class":204},[199,1072,1073],{"class":256},"\"__api_",[199,1075,391],{"class":246},[199,1077,1078],{"class":208},"uuid4()",[199,1080,400],{"class":246},[199,1082,1083],{"class":256},"__\"",[199,1085,488],{"class":208},[199,1087,1088],{"class":166,"line":491},[199,1089,1090],{"class":208},"                }\n",[199,1092,1094],{"class":166,"line":1093},21,[199,1095,1096],{"class":208},"            }\n",[199,1098,1100,1102],{"class":166,"line":1099},22,[199,1101,302],{"class":204},[199,1103,1104],{"class":208}," _:\n",[199,1106,1108,1110],{"class":166,"line":1107},23,[199,1109,313],{"class":204},[199,1111,980],{"class":208},[199,1113,1115],{"class":166,"line":1114},24,[199,1116,230],{"emptyLinePlaceholder":229},[199,1118,1120,1122,1124],{"class":166,"line":1119},25,[199,1121,416],{"class":208},[199,1123,266],{"class":204},[199,1125,421],{"class":208},[199,1127,1129,1132,1135],{"class":166,"line":1128},26,[199,1130,1131],{"class":208},"    os.environ[",[199,1133,1134],{"class":256},"\"SQLALCHEMY_URL\"",[199,1136,1137],{"class":208},"],\n",[199,1139,1141,1144,1146,1149,1152,1154,1157,1160,1162,1165,1168,1171],{"class":166,"line":1140},27,[199,1142,1143],{"class":433},"    poolclass",[199,1145,266],{"class":204},[199,1147,1148],{"class":208},"NullPool ",[199,1150,1151],{"class":204},"if",[199,1153,284],{"class":208},[199,1155,1156],{"class":256},"\"DB_SERVERLESS\"",[199,1158,1159],{"class":208},") ",[199,1161,921],{"class":204},[199,1163,1164],{"class":256}," \"1\"",[199,1166,1167],{"class":204}," else",[199,1169,1170],{"class":246}," None",[199,1172,488],{"class":208},[199,1174,1176,1179],{"class":166,"line":1175},28,[199,1177,1178],{"class":204},"    **",[199,1180,1181],{"class":208},"pooler_kwargs(),\n",[199,1183,1185],{"class":166,"line":1184},29,[199,1186,332],{"class":208},[14,1188,1189,1190,1194],{},"Count the failure modes. The psycopg 3 path has one knob and forgetting it costs you nothing but a little planning time on repeated queries. The asyncpg path has two knobs, and forgetting either produces an intermittent production error that only appears under load. That asymmetry is worth more than 45 percent on a microbenchmark. If your pool is already misbehaving, ",[31,1191,1193],{"href":1192},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","fixing connection pool exhaustion"," covers the sizing side of the same problem.",[43,1196,1198],{"id":1197},"type-handling-strict-versus-forgiving","Type handling: strict versus forgiving",[14,1200,1201,1203,1204,1207,1208,1211,1212,1215,1216,1219],{},[18,1202,20],{}," is strict. It maps Postgres types to Python types with no implicit casting: a ",[18,1205,1206],{},"uuid"," column wants a ",[18,1209,1210],{},"uuid.UUID",", not a string, and passing the wrong type raises ",[18,1213,1214],{},"DataError"," rather than letting Postgres sort it out. JSON and JSONB come back as raw strings unless you register a codec. That strictness catches bugs early, and it also means a careless ",[18,1217,1218],{},"str(user_id)"," somewhere in your code becomes a 500 instead of a silent success.",[14,1221,1222,1224,1225,1227,1228,1231,1232,1235],{},[18,1223,24],{}," 3 is forgiving in the way ",[18,1226,59],{}," taught everyone to expect. Dictionaries go in and out of JSONB through the ",[18,1229,1230],{},"Json","\u002F",[18,1233,1234],{},"Jsonb"," wrappers, ranges and arrays have first-class adapters, and unknown types fall back to text so Postgres applies its own cast.",[190,1237,1239],{"className":192,"code":1238,"language":194,"meta":195,"style":195},"import json\nimport os\nimport asyncpg  # strict: register a codec or JSONB arrives as a string\n\nasync def read_events_asyncpg() -> list[dict]:\n    conn = await asyncpg.connect(os.environ[\"DATABASE_URL\"])\n    try:\n        await conn.set_type_codec(\n            \"jsonb\", encoder=json.dumps, decoder=json.loads, schema=\"pg_catalog\"\n        )\n        rows = await conn.fetch(\"SELECT payload FROM usage_events LIMIT $1\", 100)\n        return [dict(r[\"payload\"]) for r in rows]\n    finally:\n        await conn.close()\n",[18,1240,1241,1248,1254,1265,1269,1288,1306,1313,1321,1352,1357,1378,1408,1415],{"__ignoreMap":195},[199,1242,1243,1245],{"class":166,"line":201},[199,1244,205],{"class":204},[199,1246,1247],{"class":208}," json\n",[199,1249,1250,1252],{"class":166,"line":212},[199,1251,205],{"class":204},[199,1253,209],{"class":208},[199,1255,1256,1258,1261],{"class":166,"line":226},[199,1257,205],{"class":204},[199,1259,1260],{"class":208}," asyncpg  ",[199,1262,1264],{"class":1263},"sJ8bj","# strict: register a codec or JSONB arrives as a string\n",[199,1266,1267],{"class":166,"line":233},[199,1268,230],{"emptyLinePlaceholder":229},[199,1270,1271,1274,1277,1280,1283,1285],{"class":166,"line":253},[199,1272,1273],{"class":204},"async",[199,1275,1276],{"class":204}," def",[199,1278,1279],{"class":239}," read_events_asyncpg",[199,1281,1282],{"class":208},"() -> list[",[199,1284,957],{"class":246},[199,1286,1287],{"class":208},"]:\n",[199,1289,1290,1293,1295,1298,1301,1303],{"class":166,"line":260},[199,1291,1292],{"class":208},"    conn ",[199,1294,266],{"class":204},[199,1296,1297],{"class":204}," await",[199,1299,1300],{"class":208}," asyncpg.connect(os.environ[",[199,1302,272],{"class":256},[199,1304,1305],{"class":208},"])\n",[199,1307,1308,1311],{"class":166,"line":278},[199,1309,1310],{"class":204},"    try",[199,1312,250],{"class":208},[199,1314,1315,1318],{"class":166,"line":299},[199,1316,1317],{"class":204},"        await",[199,1319,1320],{"class":208}," conn.set_type_codec(\n",[199,1322,1323,1326,1328,1331,1333,1336,1339,1341,1344,1347,1349],{"class":166,"line":310},[199,1324,1325],{"class":256},"            \"jsonb\"",[199,1327,290],{"class":208},[199,1329,1330],{"class":433},"encoder",[199,1332,266],{"class":204},[199,1334,1335],{"class":208},"json.dumps, ",[199,1337,1338],{"class":433},"decoder",[199,1340,266],{"class":204},[199,1342,1343],{"class":208},"json.loads, ",[199,1345,1346],{"class":433},"schema",[199,1348,266],{"class":204},[199,1350,1351],{"class":256},"\"pg_catalog\"\n",[199,1353,1354],{"class":166,"line":335},[199,1355,1356],{"class":208},"        )\n",[199,1358,1359,1362,1364,1366,1369,1372,1374,1376],{"class":166,"line":345},[199,1360,1361],{"class":208},"        rows ",[199,1363,266],{"class":204},[199,1365,1297],{"class":204},[199,1367,1368],{"class":208}," conn.fetch(",[199,1370,1371],{"class":256},"\"SELECT payload FROM usage_events LIMIT $1\"",[199,1373,290],{"class":208},[199,1375,782],{"class":246},[199,1377,332],{"class":208},[199,1379,1380,1382,1385,1387,1390,1393,1396,1399,1402,1405],{"class":166,"line":365},[199,1381,977],{"class":204},[199,1383,1384],{"class":208}," [",[199,1386,957],{"class":246},[199,1388,1389],{"class":208},"(r[",[199,1391,1392],{"class":256},"\"payload\"",[199,1394,1395],{"class":208},"]) ",[199,1397,1398],{"class":204},"for",[199,1400,1401],{"class":208}," r ",[199,1403,1404],{"class":204},"in",[199,1406,1407],{"class":208}," rows]\n",[199,1409,1410,1413],{"class":166,"line":373},[199,1411,1412],{"class":204},"    finally",[199,1414,250],{"class":208},[199,1416,1417,1419],{"class":166,"line":408},[199,1418,1317],{"class":204},[199,1420,1421],{"class":208}," conn.close()\n",[190,1423,1425],{"className":192,"code":1424,"language":194,"meta":195,"style":195},"import os\nimport psycopg  # forgiving: dicts adapt straight into JSONB\nfrom psycopg.types.json import Jsonb\n\nasync def write_event_psycopg(payload: dict) -> None:\n    async with await psycopg.AsyncConnection.connect(os.environ[\"DATABASE_URL\"]) as conn:\n        async with conn.cursor() as cur:\n            await cur.execute(\n                \"INSERT INTO usage_events (payload) VALUES (%s)\", (Jsonb(payload),)\n            )\n        await conn.commit()\n",[18,1426,1427,1433,1443,1455,1459,1480,1503,1518,1526,1540,1545],{"__ignoreMap":195},[199,1428,1429,1431],{"class":166,"line":201},[199,1430,205],{"class":204},[199,1432,209],{"class":208},[199,1434,1435,1437,1440],{"class":166,"line":212},[199,1436,205],{"class":204},[199,1438,1439],{"class":208}," psycopg  ",[199,1441,1442],{"class":1263},"# forgiving: dicts adapt straight into JSONB\n",[199,1444,1445,1447,1450,1452],{"class":166,"line":226},[199,1446,215],{"class":204},[199,1448,1449],{"class":208}," psycopg.types.json ",[199,1451,205],{"class":204},[199,1453,1454],{"class":208}," Jsonb\n",[199,1456,1457],{"class":166,"line":233},[199,1458,230],{"emptyLinePlaceholder":229},[199,1460,1461,1463,1465,1468,1471,1473,1476,1478],{"class":166,"line":253},[199,1462,1273],{"class":204},[199,1464,1276],{"class":204},[199,1466,1467],{"class":239}," write_event_psycopg",[199,1469,1470],{"class":208},"(payload: ",[199,1472,957],{"class":246},[199,1474,1475],{"class":208},") -> ",[199,1477,1019],{"class":246},[199,1479,250],{"class":208},[199,1481,1482,1485,1488,1490,1493,1495,1497,1500],{"class":166,"line":260},[199,1483,1484],{"class":204},"    async",[199,1486,1487],{"class":204}," with",[199,1489,1297],{"class":204},[199,1491,1492],{"class":208}," psycopg.AsyncConnection.connect(os.environ[",[199,1494,272],{"class":256},[199,1496,1395],{"class":208},[199,1498,1499],{"class":204},"as",[199,1501,1502],{"class":208}," conn:\n",[199,1504,1505,1508,1510,1513,1515],{"class":166,"line":278},[199,1506,1507],{"class":204},"        async",[199,1509,1487],{"class":204},[199,1511,1512],{"class":208}," conn.cursor() ",[199,1514,1499],{"class":204},[199,1516,1517],{"class":208}," cur:\n",[199,1519,1520,1523],{"class":166,"line":299},[199,1521,1522],{"class":204},"            await",[199,1524,1525],{"class":208}," cur.execute(\n",[199,1527,1528,1531,1534,1537],{"class":166,"line":310},[199,1529,1530],{"class":256},"                \"INSERT INTO usage_events (payload) VALUES (",[199,1532,1533],{"class":246},"%s",[199,1535,1536],{"class":256},")\"",[199,1538,1539],{"class":208},", (Jsonb(payload),)\n",[199,1541,1542],{"class":166,"line":335},[199,1543,1544],{"class":208},"            )\n",[199,1546,1547,1549],{"class":166,"line":345},[199,1548,1317],{"class":204},[199,1550,1551],{"class":208}," conn.commit()\n",[14,1553,1554,1555,1558,1559,1562],{},"Under SQLAlchemy the dialects paper over most of this: declare a column as ",[18,1556,1557],{},"JSONB"," and both drivers hand you a dict. The difference surfaces the moment you drop to ",[18,1560,1561],{},"text()"," for a query the ORM cannot express, which every real API does eventually.",[14,1564,1565,1566,1569,1570,1573,1574,1578],{},"One more asyncpg quirk deserves a warning. Because it caches statement plans and type OIDs per connection, a migration that alters a table or recreates an enum can leave live connections holding stale plans, producing ",[18,1567,1568],{},"InvalidCachedStatementError"," on the next execute. Setting ",[18,1571,1572],{},"pool_pre_ping=True"," and recycling connections after a migration mitigates it, but it is a genuine hazard during ",[31,1575,1577],{"href":1576},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Fzero-downtime-deploys-for-python-apis\u002F","zero-downtime deploys",". psycopg 3 re-plans and never hits it.",[43,1580,1582],{"id":1581},"which-to-pick-per-hosting-model","Which to pick per hosting model",[1584,1585,1586,1602],"table",{},[1587,1588,1589],"thead",{},[1590,1591,1592,1596,1599],"tr",{},[1593,1594,1595],"th",{},"Hosting model",[1593,1597,1598],{},"Pick",[1593,1600,1601],{},"Why",[1603,1604,1605,1616,1626,1636,1646],"tbody",{},[1590,1606,1607,1611,1613],{},[1608,1609,1610],"td",{},"Dedicated Postgres, direct connection",[1608,1612,20],{},[1608,1614,1615],{},"Nothing blocks caching; take the speed",[1590,1617,1618,1621,1623],{},[1608,1619,1620],{},"Supabase \u002F Neon pooled port",[1608,1622,502],{},[1608,1624,1625],{},"One flag, no intermittent errors",[1590,1627,1628,1631,1633],{},[1608,1629,1630],{},"Serverless or scale-to-zero",[1608,1632,502],{},[1608,1634,1635],{},"NullPool plus no prepared state",[1590,1637,1638,1641,1643],{},[1608,1639,1640],{},"Bulk export or analytics jobs",[1608,1642,20],{},[1608,1644,1645],{},"Binary decode wins on wide result sets",[1590,1647,1648,1651,1653],{},[1608,1649,1650],{},"Mixed sync and async codebase",[1608,1652,502],{},[1608,1654,1655],{},"One driver for Alembic and the app",[69,1657,78,1662,78,1665,78,1668,78,1671,78,1675,78,1679,78,1684,78,1687,78,1692,78,1696,78,1701,78,1705,78,1709,78,1713,78,1716,78,1721,78,1724,78,1727,78,1731,78,1735,78,1738,78,1742,78,1745,78,1747,78,1750,78,1753],{"viewBox":1658,"role":72,"ariaLabelledBy":1659,"xmlns":76,"style":77},"0 0 760 360",[1660,1661],"apg3-tree-t","apg3-tree-d",[80,1663,1664],{"id":1660},"Driver decision tree",[84,1666,1667],{"id":1661},"A decision tree: connect directly and choose asyncpg; go through a transaction pooler and choose psycopg 3 with prepare threshold disabled, or asyncpg with statement caching off.",[88,1669],{"x":90,"y":90,"width":91,"height":1670,"fill":93},"360",[88,1672],{"x":542,"y":98,"width":1673,"height":606,"rx":1674,"fill":107,"stroke":753,"style":109},"260","10",[95,1676,1678],{"x":97,"y":1677,"fill":99,"style":113},"53","Do you go through a pooler?",[1680,1681],"polyline",{"points":1682,"fill":1683,"stroke":108,"style":109},"380,70 380,100 170,100 170,130","none",[1680,1685],{"points":1686,"fill":1683,"stroke":108,"style":109},"380,100 535,100 535,130",[95,1688,1691],{"x":1689,"y":1690,"fill":580,"style":664},"266","94","no",[95,1693,1695],{"x":1694,"y":1690,"fill":580,"style":664},"470","yes",[88,1697],{"x":1698,"y":605,"width":1699,"height":1700,"rx":1674,"fill":93,"stroke":120,"style":109},"60","220","72",[95,1702,1704],{"x":751,"y":1703,"fill":99,"style":113},"158","Direct connection",[95,1706,1708],{"x":751,"y":1707,"fill":99,"style":601},"178","Choose asyncpg",[95,1710,1712],{"x":751,"y":1711,"fill":580,"style":664},"194","keep the statement cache",[88,1714],{"x":1715,"y":605,"width":585,"height":606,"rx":1674,"fill":107,"stroke":753,"style":109},"435",[95,1717,1720],{"x":1718,"y":1719,"fill":99,"style":113},"535","159","Transaction pooling?",[1680,1722],{"points":1723,"fill":1683,"stroke":108,"style":109},"535,176 535,206 425,206 425,230",[1680,1725],{"points":1726,"fill":1683,"stroke":108,"style":109},"535,206 645,206 645,230",[88,1728],{"x":1729,"y":817,"width":585,"height":1730,"rx":1674,"fill":93,"stroke":129,"style":109},"325","76",[95,1732,502],{"x":1733,"y":1734,"fill":99,"style":113},"425","256",[95,1736,839],{"x":1733,"y":1737,"fill":99,"style":664},"276",[95,1739,1741],{"x":1733,"y":1740,"fill":580,"style":664},"294","one flag, hard to botch",[88,1743],{"x":1744,"y":817,"width":585,"height":1730,"rx":1674,"fill":93,"stroke":120,"style":109},"545",[95,1746,20],{"x":770,"y":1734,"fill":99,"style":113},[95,1748,1749],{"x":770,"y":1737,"fill":99,"style":664},"statement_cache_size=0",[95,1751,1752],{"x":770,"y":1740,"fill":580,"style":664},"plus unique statement names",[95,1754,1756],{"x":97,"y":1755,"fill":580,"style":664},"336","Serverless or scale-to-zero: add NullPool on either branch",[14,1758,1759,1760,1764],{},"Traffic level barely enters this. Below roughly a thousand requests per minute, neither driver is your bottleneck — your bottleneck is query shape and pool size. Above that, and only on a dedicated instance where you control the connection path, asyncpg's binary decoding starts buying you measurable headroom per container, which is real money when you are running on a per-second billed host. The ",[31,1761,1763],{"href":1762},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdeploying-apis-to-render-or-vercel\u002Frender-vs-railway-vs-fly-io\u002F","Render vs Railway vs Fly.io"," comparison covers which of those hosts even gives you a direct connection.",[43,1766,1768],{"id":1767},"migrating-from-one-to-the-other","Migrating from one to the other",[14,1770,1771],{},"The move is genuinely small — a couple of hours, most of it testing. Do it in this order:",[1773,1774,1775,1787,1794,1815,1838,1853,1861],"ol",{},[1776,1777,1778,1779,1782,1783,1786],"li",{},"Install the other driver alongside the current one: ",[18,1780,1781],{},"pip install \"psycopg[binary,pool]\""," or ",[18,1784,1785],{},"pip install asyncpg",". Keep both until you have shipped.",[1776,1788,1789,1790,1793],{},"Change the URL scheme only, via the ",[18,1791,1792],{},"DB_DRIVER"," environment variable from the first snippet. Nothing in your models or queries changes.",[1776,1795,1796,1797,1799,1800,1803,1804,1807,1808,290,1811,1814],{},"Grep for raw SQL. ",[18,1798,1561],{}," blocks using ",[18,1801,1802],{},":name"," bind parameters are portable. Anything reaching the DBAPI directly — ",[18,1805,1806],{},"$1"," placeholders, ",[18,1809,1810],{},"conn.fetch",[18,1812,1813],{},"copy_records_to_table"," — needs rewriting.",[1776,1816,1817,1818,1821,1822,1825,1826,1829,1830,1833,1834,1837],{},"Move driver-specific ",[18,1819,1820],{},"connect_args"," across. ",[18,1823,1824],{},"server_settings"," on asyncpg becomes ",[18,1827,1828],{},"options=\"-c ...\""," on psycopg, and ",[18,1831,1832],{},"command_timeout"," becomes a statement timeout set through ",[18,1835,1836],{},"options",".",[1776,1839,1840,1841,1844,1845,1848,1849,1852],{},"Update exception handling. Catch ",[18,1842,1843],{},"sqlalchemy.exc.DBAPIError"," and inspect ",[18,1846,1847],{},"orig",", rather than importing ",[18,1850,1851],{},"asyncpg.exceptions"," directly, and the code stays driver-agnostic forever.",[1776,1854,1855,1856,1860],{},"Re-run the suite against a real Postgres, not SQLite. If you have not set that up, ",[31,1857,1859],{"href":1858},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest"," covers the fixtures.",[1776,1862,1863],{},"Deploy to one instance first and watch p95 query latency for an hour before rolling the rest.",[14,1865,1866],{},"Step five is the one people skip and regret. Driver-specific exception imports are what turn a two-hour migration into a two-day one.",[43,1868,1870],{"id":1869},"builder-verdict","Builder verdict",[14,1872,1873,1874,1877],{},"Ship ",[701,1875,1876],{},"psycopg 3"," unless you have a specific reason not to. It is the driver that survives contact with the hosting reality most builders actually have: a managed Postgres behind a transaction pooler, an Alembic setup that wants a synchronous connection, and a team of one or two people who cannot afford to debug intermittent prepared-statement errors at eleven at night. Disabling its prepared statements is one keyword argument, its type adapters behave the way every Python developer already expects, and using one driver for both sync tooling and the async app removes a whole category of configuration drift. The performance you give up is roughly ten percent of the driver's share of a request that is dominated by ORM hydration and network time — call it a dollar or two a month at a million requests, which is less than one support conversation costs you in attention.",[14,1879,1880,1881,1883],{},"Switch to ",[701,1882,20],{}," when you have earned the right to: a dedicated Postgres instance you connect to directly, a profile that shows the driver on the hot path, or bulk endpoints where binary decoding of wide result sets is the actual bottleneck. That is a real win at that point, and the migration above takes an afternoon. Until then, optimise for the errors you will not have to debug rather than the microseconds you will not be able to measure.",[43,1885,1887],{"id":1886},"faq","FAQ",[14,1889,1890,1893],{},[701,1891,1892],{},"Does driver choice measurably change my hosting bill?","\nOnly at scale and only on a direct connection. At a million requests a month with three queries each, the ORM-layer difference works out to single-digit dollars of compute. Below ten million requests a month, spend that attention on query indexes and caching instead — both move the number by an order of magnitude more.",[14,1895,1896,1899,1900,1902],{},[701,1897,1898],{},"Which driver is safer on Supabase or Neon?","\npsycopg 3, because their pooled connection strings run transaction pooling and psycopg 3 needs one flag (",[18,1901,839],{},") to be compatible. asyncpg works too, but needs both a zeroed statement cache and a unique statement-name function; miss either and you get intermittent 500s under concurrency that never reproduce locally.",[14,1904,1905,1908,1909,1911,1912,1915],{},[701,1906,1907],{},"How risky is migrating a live API between the two?","\nLow, if you keep the switch behind an environment variable and roll one instance at a time. The risk concentrates in three places: raw DBAPI-level SQL, driver-specific ",[18,1910,1820],{},", and ",[18,1913,1914],{},"except asyncpg.SomeError"," imports. Fix those first and the rest is a URL scheme change you can roll back in seconds.",[14,1917,1918,1921],{},[701,1919,1920],{},"Can I run asyncpg for the API and psycopg for Alembic?","\nYes, and plenty of projects do — Alembic runs migrations on a separate connection and does not care which driver serves requests. It does mean two drivers to keep patched and two sets of connection quirks in your head, which is exactly the tax psycopg 3 removes by handling both modes itself.",[14,1923,1924,1927],{},[701,1925,1926],{},"Do I still need a connection pooler if I use asyncpg?","\nAlmost always, yes. SQLAlchemy's pool lives inside one process, so ten containers with a pool of ten each demand a hundred backend connections from a database that often allows sixty. A pooler multiplexes that down. The driver never changes that arithmetic — it only changes how gracefully it copes with the pooler being there.",[43,1929,1931],{"id":1930},"related","Related",[14,1933,1934],{},[701,1935,1936],{},"Same track:",[1938,1939,1940,1945,1951,1958],"ul",{},[1776,1941,1942,1944],{},[31,1943,34],{"href":33}," — the parent guide, with engine lifecycle, pool sizing, and per-request transactions.",[1776,1946,1947,1950],{},[31,1948,1949],{"href":1192},"Fixing Connection Pool Exhaustion"," — what to do when requests queue behind a full pool regardless of driver.",[1776,1952,1953,1957],{},[31,1954,1956],{"href":1955},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," — the wider operations area this page sits in.",[1776,1959,1960,1964],{},[31,1961,1963],{"href":1962},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","Structured Logging with structlog"," — how to log query latency so a driver swap is measurable rather than a guess.",[14,1966,1967],{},[701,1968,1969],{},"Other tracks:",[1938,1971,1972,1979],{},[1776,1973,1974,1978],{},[31,1975,1977],{"href":1976},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Ftracking-api-usage-and-analytics\u002Flogging-api-usage-events-to-postgres\u002F","Logging API Usage Events to Postgres"," — the write-heavy workload where bulk insert performance actually matters.",[1776,1980,1981,1984],{},[31,1982,1983],{"href":684},"Calculating Cost per API Request"," — put a real number on the compute you save before optimising it.",[1986,1987,1988],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":195,"searchDepth":212,"depth":212,"links":1990},[1991,1992,1993,1994,1995,1996,1997,1998,1999],{"id":45,"depth":212,"text":46},{"id":506,"depth":212,"text":507},{"id":695,"depth":212,"text":696},{"id":1197,"depth":212,"text":1198},{"id":1581,"depth":212,"text":1582},{"id":1767,"depth":212,"text":1768},{"id":1869,"depth":212,"text":1870},{"id":1886,"depth":212,"text":1887},{"id":1930,"depth":212,"text":1931},"Compare asyncpg and psycopg3 under async SQLAlchemy: real latency numbers, prepared statements behind PgBouncer, type handling, and which to run per host.","md",{"pageTitle":2003,"type":2004,"datePublished":2005,"dateModified":2005},"asyncpg vs psycopg3 for FastAPI: Async Driver Choice","article","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi",{"title":5,"description":2000},"scaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Fasyncpg-vs-psycopg3-for-fastapi\u002Findex","jjb7YBQmjrVSdDFhjzYZ1aGWP_i4Fbs8R4tyQbo_Lkc",{"@context":2011,"@type":2012,"mainEntity":2013},"https:\u002F\u002Fschema.org","FAQPage",[2014,2019,2022,2025,2028],{"@type":2015,"name":1892,"acceptedAnswer":2016},"Question",{"@type":2017,"text":2018},"Answer","Only at scale and only on a direct connection. At a million requests a month with three queries each, the ORM-layer difference works out to single-digit dollars of compute. Below ten million requests a month, spend that attention on query indexes and caching instead — both move the number by an order of magnitude more.",{"@type":2015,"name":1898,"acceptedAnswer":2020},{"@type":2017,"text":2021},"psycopg 3, because their pooled connection strings run transaction pooling and psycopg 3 needs one flag (prepare_threshold=None) to be compatible. asyncpg works too, but needs both a zeroed statement cache and a unique statement-name function; miss either and you get intermittent 500s under concurrency that never reproduce locally.",{"@type":2015,"name":1907,"acceptedAnswer":2023},{"@type":2017,"text":2024},"Low, if you keep the switch behind an environment variable and roll one instance at a time. The risk concentrates in three places: raw DBAPI-level SQL, driver-specific connect_args, and except asyncpg.SomeError imports. Fix those first and the rest is a URL scheme change you can roll back in seconds.",{"@type":2015,"name":1920,"acceptedAnswer":2026},{"@type":2017,"text":2027},"Yes, and plenty of projects do — Alembic runs migrations on a separate connection and does not care which driver serves requests. It does mean two drivers to keep patched and two sets of connection quirks in your head, which is exactly the tax psycopg 3 removes by handling both modes itself.",{"@type":2015,"name":1926,"acceptedAnswer":2029},{"@type":2017,"text":2030},"Almost always, yes. SQLAlchemy's pool lives inside one process, so ten containers with a pool of ten each demand a hundred backend connections from a database that often allows sixty. A pooler multiplexes that down. The driver never changes that arithmetic — it only changes how gracefully it copes with the pooler being there.",1784887028601]