{"id":470,"date":"2026-07-17T05:04:05","date_gmt":"2026-07-17T05:04:05","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/fastapi-async-2026-def-vs-async-def-the-choice-i-keep-getting-right\/"},"modified":"2026-07-17T05:04:05","modified_gmt":"2026-07-17T05:04:05","slug":"fastapi-async-2026-def-vs-async-def-the-choice-i-keep-getting-right","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/fastapi-async-2026-def-vs-async-def-the-choice-i-keep-getting-right\/","title":{"rendered":"FastAPI Async in 2026: The def vs async def Choice I Keep Getting Right"},"content":{"rendered":"<p>Short version for the impatient: in FastAPI, <code>async def<\/code> doesn&rsquo;t make your endpoint faster. It just means &ldquo;please run me on the event loop, and I promise not to block it&rdquo;. If you break that promise, one slow request can freeze every other request the worker is handling. If you use plain <code>def<\/code>, FastAPI runs the endpoint in a threadpool and blocking is fine. That&rsquo;s the whole trick, and I&rsquo;ve watched senior engineers get it backwards for years.<\/p>\n<p>I&rsquo;m writing this because I did an on-call rotation last month where the paging alert was &ldquo;P99 latency 30 seconds&rdquo; and the root cause was a single <code>requests.get()<\/code> call sitting inside an <code>async def<\/code> handler. The fix was a one-word edit. The pain was two hours of me squinting at flame graphs first.<\/p>\n<h2 id=\"why-async-def-is-a-promise-not-a-superpower\">Why <code>async def<\/code> is a promise, not a superpower<\/h2>\n<p>FastAPI&rsquo;s routing is a two-tier system, and the <a href=\"https:\/\/fastapi.tiangolo.com\/async\/\" rel=\"nofollow noopener\" target=\"_blank\">official concurrency docs<\/a> spell this out if you read carefully. When you declare a route:<\/p>\n<pre><code class=\"language-python\">@app.get(&quot;\/thing&quot;)\nasync def get_thing():\n    ...\n<\/code><\/pre>\n<p>FastAPI schedules this on the event loop. That loop is single-threaded per worker. If your handler does anything blocking (a sync HTTP call, a sync DB query, a <code>time.sleep<\/code>, a heavy CPU loop), that entire worker stops serving other requests until it returns.<\/p>\n<p>Change the signature:<\/p>\n<pre><code class=\"language-python\">@app.get(&quot;\/thing&quot;)\ndef get_thing():\n    ...\n<\/code><\/pre>\n<p>Now FastAPI runs the function in an anyio threadpool (default 40 threads, tunable via <code>anyio.to_thread.current_default_thread_limiter().total_tokens<\/code>). Blocking calls are fine because the event loop stays free. You lose zero performance for CPU-light endpoints, and you get correctness for free. The <a href=\"https:\/\/anyio.readthedocs.io\/en\/stable\/threads.html\" rel=\"nofollow noopener\" target=\"_blank\">anyio threading docs<\/a> explain the exact semantics if you want the underlying model.<\/p>\n<p>The rule I keep in my head: <strong>use <code>async def<\/code> only if every I\/O call inside it is genuinely async<\/strong>. Otherwise use plain <code>def<\/code>. Not &ldquo;async def because it&rsquo;s the modern one&rdquo;. Not &ldquo;async def because I use FastAPI&rdquo;. <code>def<\/code> unless you actually mean it.<\/p>\n<h2 id=\"the-failure-mode-nobody-warns-you-about\">The failure mode nobody warns you about<\/h2>\n<p>Here&rsquo;s the incident from last month, sanitized. This is the code that shipped:<\/p>\n<pre><code class=\"language-python\">import requests\nfrom fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get(&quot;\/user\/{uid}\/profile&quot;)\nasync def get_profile(uid: str):\n    # calling a slow internal service\n    r = requests.get(f&quot;http:\/\/users-svc\/internal\/{uid}&quot;, timeout=30)\n    return r.json()\n<\/code><\/pre>\n<p>Under normal load, <code>requests.get<\/code> returned in 40ms and nothing looked wrong. Then the internal users service had a 20-second cold-start after a deploy. Every single in-flight request on that worker queued behind the blocked handler, because <code>requests<\/code> is sync and it froze the event loop. P99 shot to 30 seconds even for <code>\/healthz<\/code>.<\/p>\n<p>The fix was one of two options:<\/p>\n<pre><code class=\"language-python\"># Option A: keep the endpoint sync, let the threadpool handle it\n@app.get(&quot;\/user\/{uid}\/profile&quot;)\ndef get_profile(uid: str):\n    r = requests.get(f&quot;http:\/\/users-svc\/internal\/{uid}&quot;, timeout=30)\n    return r.json()\n<\/code><\/pre>\n<pre><code class=\"language-python\"># Option B: go fully async with httpx\nimport httpx\n\n@app.get(&quot;\/user\/{uid}\/profile&quot;)\nasync def get_profile(uid: str):\n    async with httpx.AsyncClient() as client:\n        r = await client.get(f&quot;http:\/\/users-svc\/internal\/{uid}&quot;, timeout=30)\n    return r.json()\n<\/code><\/pre>\n<p>Option A is a one-word change. Option B is what you want if this endpoint is hot and you&rsquo;re doing fan-out to several services from the same handler. Both are correct. The half-and-half version I shipped was neither.<\/p>\n<h2 id=\"where-async-actually-pays-off\">Where async actually pays off<\/h2>\n<p>I don&rsquo;t want to give the impression that <code>async def<\/code> is a footgun with no upside. It isn&rsquo;t. It&rsquo;s a huge win in three specific shapes:<\/p>\n<p><strong>Fan-out I\/O.<\/strong> One request needs to hit five services or five DB queries that don&rsquo;t depend on each other. With <code>async def<\/code> and <code>asyncio.gather<\/code>, you fire them in parallel and wait for the slowest. The threadpool version has to serialize or thread-fan them yourself.<\/p>\n<pre><code class=\"language-python\">import asyncio, httpx\n\n@app.get(&quot;\/dashboard\/{uid}&quot;)\nasync def dashboard(uid: str):\n    async with httpx.AsyncClient() as client:\n        user, orders, notifs = await asyncio.gather(\n            client.get(f&quot;\/users\/{uid}&quot;),\n            client.get(f&quot;\/orders?uid={uid}&quot;),\n            client.get(f&quot;\/notifications?uid={uid}&quot;),\n        )\n    return {&quot;user&quot;: user.json(), &quot;orders&quot;: orders.json(), &quot;notifs&quot;: notifs.json()}\n<\/code><\/pre>\n<p><strong>Long-lived connections.<\/strong> Websockets, SSE streams, LLM token streaming. The event loop is the correct primitive here. Trying to hold 5,000 threads open for 5,000 open streams will run you out of memory before it runs you out of CPU.<\/p>\n<p><strong>Background waiting.<\/strong> Anything that spends 99% of its time waiting on the network. An async worker with 4 CPUs will comfortably handle 10,000 concurrent I\/O-bound connections. A threaded worker maxes out around a few hundred before context switching costs eat you.<\/p>\n<p>A caveat worth calling out: if you&rsquo;re running FastAPI behind Uvicorn workers and you set <code>--workers 8<\/code> on an 8-core box, each worker has its own event loop and its own threadpool. Blocking one worker doesn&rsquo;t block the others, so a mixed handler is less catastrophic in production than it looks in a local one-worker test. It&rsquo;s still wrong, it just fails less loudly.<\/p>\n<p>If your endpoint doesn&rsquo;t fit one of those shapes, <code>def<\/code> is almost always the correct choice, and you should stop feeling guilty about it.<\/p>\n<h2 id=\"the-database-question-sqlalchemy-20-makes-this-easier\">The database question (SQLAlchemy 2.0 makes this easier)<\/h2>\n<p>The most common trap I see now: <code>async def<\/code> handler + sync SQLAlchemy session. Congratulations, you just moved every DB query onto the event loop with no way for it to yield.<\/p>\n<p>If you want async endpoints and a real DB, use the async session:<\/p>\n<pre><code class=\"language-python\">from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession\nfrom sqlalchemy.orm import sessionmaker\n\nengine = create_async_engine(&quot;postgresql+asyncpg:\/\/...&quot;)\nAsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n\nasync def get_session():\n    async with AsyncSessionLocal() as session:\n        yield session\n\n@app.get(&quot;\/orders\/{oid}&quot;)\nasync def get_order(oid: int, db: AsyncSession = Depends(get_session)):\n    result = await db.execute(select(Order).where(Order.id == oid))\n    return result.scalar_one()\n<\/code><\/pre>\n<p>SQLAlchemy 2.0&rsquo;s async API is genuinely nice. It&rsquo;s the same select\/insert\/update shape as sync, just with <code>await<\/code> on <code>execute<\/code> and <code>commit<\/code>. The <a href=\"https:\/\/docs.sqlalchemy.org\/en\/20\/orm\/extensions\/asyncio.html\" rel=\"nofollow noopener\" target=\"_blank\">SQLAlchemy 2.0 async docs<\/a> are the canonical reference and they don&rsquo;t hide the sharp edges (no lazy loading, be careful with <code>expire_on_commit<\/code>).<\/p>\n<p>If you&rsquo;re not ready to migrate to async DB, keep your endpoint as <code>def<\/code> and use the sync session. That&rsquo;s a perfectly fine production choice. Don&rsquo;t mix.<\/p>\n<h2 id=\"how-i-write-new-endpoints-now\">How I write new endpoints now<\/h2>\n<p>My mental checklist before writing a new route:<\/p>\n<ol>\n<li><strong>List every I\/O call this endpoint will make.<\/strong> DB, HTTP, cache, filesystem, subprocess.<\/li>\n<li><strong>Is every one of them async-native?<\/strong> (async DB session, <code>httpx.AsyncClient<\/code>, <code>aioredis<\/code>, etc.)<\/li>\n<li><strong>If yes<\/strong> \u2192 <code>async def<\/code>.<\/li>\n<li><strong>If no, or you&rsquo;re not sure<\/strong> \u2192 <code>def<\/code>.<\/li>\n<li><strong>If it&rsquo;s mostly CPU work<\/strong> \u2192 <code>def<\/code>, and consider offloading to a Celery worker if it&rsquo;s heavy.<\/li>\n<\/ol>\n<p>That&rsquo;s it. Five bullets, taped mentally to the inside of my monitor. You don&rsquo;t need to think harder than this. The rule captures every case I&rsquo;ve hit in five years of shipping FastAPI, including the ones I got wrong.<\/p>\n<p>If you want to sanity-check whether your existing service has the problem, throw <code>asyncio.run(asyncio.sleep(0))<\/code> in your endpoint and load-test it. If a slow endpoint blocks a fast one, you have a mixed handler somewhere. I built a small profiler for this a while back and wrote about it in my <a href=\"https:\/\/abrarqasim.com\/blog\/postgres-full-text-search-2026-when-i-reach-for-it-over-elasticsearch\" rel=\"noopener\">post on Postgres full-text search<\/a>, where I hit the same event-loop trap on a search endpoint.<\/p>\n<h2 id=\"what-id-do-this-week-if-i-inherited-a-fastapi-codebase\">What I&rsquo;d do this week if I inherited a FastAPI codebase<\/h2>\n<p>Run this ripgrep in the repo:<\/p>\n<pre><code>rg -A 3 'async def' | rg -B 1 -E '(requests\\.|psycopg2|pymysql|time\\.sleep|subprocess\\.run)'\n<\/code><\/pre>\n<p>Every match is a place someone put a blocking call inside an async handler. Convert the endpoint to plain <code>def<\/code> or swap the library for an async one. That&rsquo;s usually an afternoon of work and it will save you a P99 incident.<\/p>\n<p>If the codebase is large and you want a longer-term fix, I keep notes on Python backend architecture over on my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">work page<\/a>. Happy to trade war stories about event loops and threadpools.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>In FastAPI, async def is a promise not a superpower. Here&#8217;s the def vs async def rule I use, the incident that taught me the hard way, and the code fixes.<\/p>\n","protected":false},"author":2,"featured_media":469,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"In FastAPI, async def is a promise not a superpower. Here's the def vs async def rule I use, the incident that taught me the hard way, and the code fixes.","rank_math_focus_keyword":"fastapi async","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,528],"tags":[532,533,531,49,212,529,530,534,535,536],"class_list":["post-470","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-python","tag-async-def","tag-async-python","tag-asyncio","tag-backend","tag-concurrency","tag-fastapi","tag-python","tag-sqlalchemy-2","tag-uvicorn","tag-web-framework-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/470","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=470"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/470\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/469"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=470"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=470"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=470"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}