Tracing FastAPI def Endpoints into AnyIO Worker Threads
A FastAPI endpoint can be written as either async def or regular def:
@app.get("/async")
async def async_endpoint():
...
@app.get("/sync")
def sync_endpoint():
...
They look similar at the routing layer, but they do not run in the same place.
An async def endpoint is awaited directly by FastAPI. A regular def endpoint is sent through Starlette’s threadpool helper, which delegates to AnyIO and eventually runs the function inside an AnyIO worker thread.
This is a companion source-code note to A PyTorch Worker SIGINT Investigation. Here, I only focus on the FastAPI, Starlette, and AnyIO execution path.
This post traces that source path:
FastAPI sync endpoint -> AnyIO worker thread
The question is simple: when a request reaches a regular def FastAPI endpoint, where does the function actually run?
Source Versions
The snippets below are based on these package versions:
| Component | Version |
|---|---|
| FastAPI | 0.137.2 |
| Starlette | 1.3.1 |
| Uvicorn | 0.49.0 |
| AnyIO | 4.13.0 |
The example endpoint is:
@app.post("/run-job")
def run_job():
result = run_blocking_job()
return {"result": result}
The important part is def run_job(), not async def run_job().
The Decorator Only Registers the Endpoint
The @app.post("/path") decorator does not execute the endpoint when the function is defined. It registers the function in FastAPI’s routing table and then returns the original function.
At the FastAPI.api_route() layer, FastAPI builds a decorator:
# fastapi/applications.py:1221-1279
def api_route(...):
def decorator(func):
self.router.add_api_route(
path,
func,
...
)
return func
return decorator
The @app.post(...) helper eventually delegates to self.router.post(...):
# fastapi/applications.py:2669-2693
return self.router.post(
path,
response_model=response_model,
status_code=status_code,
...
)
Inside APIRouter.post(), the HTTP method is fixed to POST:
# fastapi/routing.py:3685-3707
status_code=status_code,
tags=tags,
dependencies=dependencies,
summary=summary,
description=description,
response_description=response_description,
responses=responses,
deprecated=deprecated,
methods=["POST"],
operation_id=operation_id,
...
Eventually, add_api_route() creates an APIRoute and appends it to self.routes:
# fastapi/routing.py:2135-2217
def add_api_route(self, path, endpoint, *, ...):
route_class = route_class_override or self.route_class
...
route = route_class(
self.prefix + path,
endpoint=endpoint,
...
)
self.routes.append(route)
self._mark_routes_changed()
So after import time, the endpoint function has only been stored:
function object run_job
-> stored in APIRoute.endpoint / dependant.call
-> APIRoute stored in router.routes
The function has not run yet.
Uvicorn Calls the ASGI App
The object passed to uvicorn.run(app, ...) is the FastAPI() instance. When Uvicorn’s HTTP protocol implementation receives a request, it creates a task to run the ASGI application:
# uvicorn/protocols/http/h11_impl.py:250-264
if self.config.reset_contextvars:
...
else:
task = self.loop.create_task(self.cycle.run_asgi(app))
task.add_done_callback(self.tasks.discard)
self.tasks.add(task)
The actual ASGI call happens inside run_asgi():
# uvicorn/protocols/http/h11_impl.py:413-417
async def run_asgi(self, app):
try:
result = await app(
self.scope, self.receive, self.send
)
In other words:
await app(scope, receive, send)
The FastAPI instance implements ASGI __call__:
# fastapi/applications.py:1159-1162
async def __call__(self, scope, receive, send):
if self.root_path:
scope["root_path"] = self.root_path
await super().__call__(scope, receive, send)
Then the Starlette application enters the middleware stack:
# starlette/applications.py:86-90
async def __call__(self, scope, receive, send):
scope["app"] = self
if self.middleware_stack is None:
self.middleware_stack = self.build_middleware_stack()
await self.middleware_stack(scope, receive, send)
At this point, the request is inside the ASGI application, but the endpoint function still has not been called.
Starlette Matches the Route
Starlette’s router iterates over self.routes and matches the request scope against path and method:
# starlette/routing.py:674-680
for route in self.routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
After a route matches, Route.handle() calls the route’s own ASGI app:
# starlette/routing.py:267-276
async def handle(self, scope, receive, send):
if self.methods and scope["method"] not in self.methods:
...
await response(scope, receive, send)
else:
await self.app(scope, receive, send)
For FastAPI, the route is an APIRoute. During initialization, APIRoute.__init__() wraps FastAPI’s generated request handler as an ASGI app:
# fastapi/routing.py:1100-1163
def __init__(self, path, endpoint, *, ...):
_populate_api_route_state(
self,
path,
endpoint,
...
)
self.app = request_response(self.get_route_handler())
The endpoint is getting closer, but FastAPI still has one important decision to make: is the endpoint a coroutine function?
FastAPI Checks Whether the Endpoint Is Async
When FastAPI builds the request handler, it reads whether the endpoint callable is a coroutine callable:
# fastapi/routing.py:376-380
def get_request_handler(...):
assert dependant.call is not None, "dependant.call must be a function"
is_coroutine = dependant.is_coroutine_callable
The logic lives on Dependant.is_coroutine_callable:
# fastapi/dependencies/models.py:157-185
@cached_property
def is_coroutine_callable(self) -> bool:
if self.call is None:
return False
if inspect.isroutine(_impartial(self.call)) and iscoroutinefunction(
_impartial(self.call)
):
return True
if inspect.isroutine(_unwrapped_call(self.call)) and iscoroutinefunction(
_unwrapped_call(self.call)
):
return True
if inspect.isclass(_unwrapped_call(self.call)):
return False
dunder_call = getattr(_impartial(self.call), "__call__", None)
...
return False
The key point is that FastAPI checks the function object itself. A regular def endpoint is not a coroutine function, so is_coroutine becomes False.
On Python 3.13, FastAPI uses inspect.iscoroutinefunction:
# fastapi/dependencies/models.py:12-15
if sys.version_info >= (3, 13):
from inspect import iscoroutinefunction
else:
from asyncio import iscoroutinefunction
That boolean decides the execution path.
Regular def Endpoints Go Through run_in_threadpool
The endpoint is eventually called by run_endpoint_function():
# fastapi/routing.py:329-339
async def run_endpoint_function(*, dependant, values, is_coroutine):
assert dependant.call is not None, "dependant.call must be a function"
if is_coroutine:
return await dependant.call(**values)
else:
return await run_in_threadpool(dependant.call, **values)
So the split is:
async def endpoint
-> await dependant.call(...)
def endpoint
-> await run_in_threadpool(dependant.call, ...)
This is the FastAPI-side reason that a synchronous endpoint does not run directly on the event loop thread.
Starlette Delegates to AnyIO
FastAPI imports and uses Starlette’s run_in_threadpool. Starlette does not maintain a separate thread pool here. It delegates to AnyIO:
# starlette/concurrency.py:32-34
async def run_in_threadpool(func, *args, **kwargs):
func = functools.partial(func, *args, **kwargs)
return await anyio.to_thread.run_sync(func)
AnyIO’s to_thread.run_sync() documents the behavior directly:
# anyio/to_thread.py:25-34
async def run_sync(
func,
*args,
abandon_on_cancel=False,
cancellable=None,
limiter=None,
):
"""
Call the given function with the given arguments in a worker thread.
It then enters the active async backend’s run_sync_in_worker_thread():
# anyio/to_thread.py:63-65
return await get_async_backend().run_sync_in_worker_thread(
func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter
)
In the asyncio backend, the worker class and thread name are explicit:
# anyio/_backends/_asyncio.py:950-963
class WorkerThread(Thread):
MAX_IDLE_TIME = 10 # seconds
def __init__(self, root_task, workers, idle_workers):
super().__init__(name="AnyIO worker thread")
self.root_task = root_task
self.workers = workers
self.idle_workers = idle_workers
self.loop = root_task._loop
The user function is executed inside the worker thread’s run() method:
# anyio/_backends/_asyncio.py:988-1003
def run(self) -> None:
with claim_worker_thread(AsyncIOBackend, self.loop):
while True:
item = self.queue.get()
if item is None:
return
context, func, args, future, cancel_scope = item
if not future.cancelled():
...
try:
result = context.run(func, *args)
except BaseException as exc:
exception = exc
This is the concrete source-level path:
def endpoint
-> FastAPI run_endpoint_function()
-> Starlette run_in_threadpool()
-> anyio.to_thread.run_sync()
-> AnyIO WorkerThread.run()
-> context.run(endpoint, *args)
So if a regular def endpoint performs blocking work, that function body runs inside an AnyIO worker thread, not in the Uvicorn/FastAPI event loop thread.
The Full Path
Putting the source path together:
@app.post("/run-job")
-> FastAPI registers an APIRoute
-> endpoint function stored in route/dependant
HTTP POST /run-job
-> Uvicorn: await app(scope, receive, send)
-> FastAPI/Starlette: router matches APIRoute
-> FastAPI: dependant.is_coroutine_callable == False
-> FastAPI: run_in_threadpool(dependant.call, ...)
-> Starlette: anyio.to_thread.run_sync(...)
-> AnyIO: "AnyIO worker thread" executes the endpoint
For an async def endpoint, the middle of the chain is different:
HTTP request
-> FastAPI/Starlette: router matches APIRoute
-> FastAPI: dependant.is_coroutine_callable == True
-> FastAPI: await dependant.call(...)
Takeaways
A regular def FastAPI endpoint is not called directly on the event loop thread. FastAPI detects that it is not a coroutine function, sends it through Starlette’s run_in_threadpool(), and Starlette delegates to AnyIO’s worker thread machinery.
That is normally a good default. It lets synchronous endpoint code run without blocking the event loop. The important distinction is that async def and def endpoints share the same routing machinery, but split at the final endpoint execution step.