Serving Data Science & AI
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. The examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on
Python 3.13 with TestClient, using pandas 3.0, Plotly 7.1 with Kaleido 1.4, NumPy 2.5, PyTorch 2.14 and
Hugging Face transformers 5.18 (the versions installed when the page was checked). A web API is often the way a data
scientist’s work reaches other people: a table of statistics, a chart, a model that completes text, a search over
meaning rather than keywords. FastAPI adds nothing special for these libraries. What it adds is a clear rule for where
slow, blocking, memory-hungry work must run so that the rest of the API stays responsive, and this page applies that
rule four times in the Bookshelf scenario (books, authors and reviews).
The page is deliberately introductory. It shows the shape of each integration and the pitfalls specific to serving; it does not teach pandas, Plotly or model training. The site’s LangChain and Hugging Face sections cover those tools in depth (including serving Hugging Face models from Python backends), and this page stays at the level of "how does the call sit inside a FastAPI application". The concurrency model behind every choice below is on Async & Concurrency, the streaming formats are on Streaming, JSON Lines & SSE, and the startup hook that loads a model once is on Settings, Lifespan & Background Tasks.
In a Hurry: Where Each Kind of Work Runs
Every example below is a variation on one decision: is the call quick and non-blocking, quick but blocking, or slow?
| Work | Where it runs | Why |
|---|---|---|
A pandas aggregation over a small in-memory frame, a NumPy matrix product |
A plain |
The call blocks while it computes; |
Rendering a chart image (Plotly with Kaleido) |
A plain |
Image export drives a browser engine and blocks for hundreds of milliseconds. |
Model inference that releases the GIL (PyTorch tensor operations mostly do) |
A plain |
The threadpool is enough; the model object is shared by all threads. |
Pure-Python, CPU-heavy work that holds the GIL, or models that must not share a process |
A |
Threads cannot run Python code in parallel (on a build with the GIL); processes can. |
Long-running or very heavy jobs, batch scoring, anything that must survive a restart |
A task queue or a dedicated inference server, outside the API process |
The request should not stay open for minutes; see When to Move to a Task Queue or a Broker. |
A pandas-Backed Endpoint
pandas is synchronous and CPU-bound, so endpoints that call it are def functions: FastAPI runs them in the threadpool
and the event loop keeps serving other requests (see
What Blocks the Event Loop, and How to Fix It). The
endpoint below aggregates review ratings per book. The interesting part is the edge of the application: a response model
turns the result into plain JSON types, and a book with no ratings does not leak a NaN.
import io
from typing import Annotated
import pandas as pd
from fastapi import FastAPI, HTTPException, Query, UploadFile
from fastapi.testclient import TestClient
from pydantic import BaseModel
REVIEWS = pd.DataFrame(
{
"book_id": [1, 1, 1, 2, 2, 3],
"reviewer": ["ana", "bo", "cy", "ana", "dee", "bo"],
"stars": [5, 4, 4, 2, 3, None],
}
)
app = FastAPI()
class BookRating(BaseModel):
book_id: int
reviews: int
mean_stars: float | None
@app.get("/stats/ratings")
def ratings(min_reviews: Annotated[int, Query(ge=1)] = 1) -> list[BookRating]:
grouped = (
REVIEWS.groupby("book_id")
.agg(reviews=("stars", "size"), mean_stars=("stars", "mean"))
.reset_index()
)
grouped = grouped[grouped["reviews"] >= min_reviews]
grouped = grouped.astype(object).where(grouped.notna(), None)
return [BookRating(**row) for row in grouped.to_dict(orient="records")]
class UploadSummary(BaseModel):
count: int
mean: float | None
@app.post("/stats/upload")
def upload_summary(file: UploadFile) -> UploadSummary:
try:
frame = pd.read_csv(file.file, usecols=["stars"])
except (ValueError, pd.errors.ParserError) as exc:
raise HTTPException(status_code=422, detail="Expected a CSV with a stars column") from exc
stars = frame["stars"]
return UploadSummary(count=int(stars.count()), mean=None if stars.empty else float(stars.mean()))
@app.get("/stats/naive")
def naive():
# NaN in the data and numpy scalars in the payload: the pitfall
return REVIEWS.groupby("book_id")["stars"].mean().to_dict()
The naive endpoint at the end returns the raw to_dict() of a series that contains a missing value, to show the failure the other
endpoints avoid: it ends in a server error (see the pitfalls). to_dict(orient="records") returns one dictionary per row, which is the shape a list of Pydantic models wants. Two
conversions deserve a comment. astype(object).where(grouped.notna(), None) replaces missing values with None:
pandas stores a missing rating as NaN (a float), JSON has no NaN, and the response model declares
mean_stars: float | None so the client sees null. And the numbers pass through BookRating(**row), so what is
serialized comes from the model, not from whatever NumPy type pandas produced. Calling the endpoint:
curl 'http://127.0.0.1:8000/stats/ratings?min_reviews=2'
curl -F 'file=@reviews.csv;type=text/csv' http://127.0.0.1:8000/stats/upload
Output of the first call (run against the example above, with min_reviews=1 shown so that the null is visible):
[
{"book_id": 1, "reviews": 3, "mean_stars": 4.333333333333333},
{"book_id": 2, "reviews": 2, "mean_stars": 2.5},
{"book_id": 3, "reviews": 1, "mean_stars": null}
]
The upload endpoint takes an UploadFile and hands its file object straight to pandas.read_csv, reading only the
column it needs (usecols). A file without a stars column raises ValueError, which the endpoint maps to a 422
response with a readable message; a valid file returns {"count": 3, "mean": 4.0}. Upload handling itself (size limits,
spooling, streaming to disk) is on Forms & File Uploads.
Where the Data Comes From
The example keeps its frame in memory to stay self-contained. A real application loads it once (in the lifespan, or on
first use behind a cache) from a database or a file, or pushes the aggregation into the database with SQL (see
SQL Databases with SQLModel) and uses pandas only where it
adds value. pandas.read_sql and similar functions are blocking calls too: run them in def functions. A frame is
mutable shared state; endpoints that write to it need the care described under "Shared State and Locks" on the
async page, and the simplest design is to treat the loaded frame as read-only and replace it as a whole.
A Plotly Chart as PNG or SVG
A chart is a response whose body is bytes and whose Content-Type says what kind. Return a Response directly,
with the image in content and the type in media_type. Plotly renders static images through Kaleido; since Kaleido 1.0
it no longer bundles a browser and looks for Chrome or Chromium on the machine (Plotly provides a
plotly_get_chrome command to install one), which matters for container images. Rendering takes noticeable time and
blocks, so the endpoint is a def.
from typing import Annotated, Literal
import pandas as pd
import plotly.express as px
from fastapi import FastAPI, Query, Response
from fastapi.testclient import TestClient
REVIEWS = pd.DataFrame(
{
"book": ["Dune", "Dune", "Dune", "Emma", "Emma", "Ulysses"],
"stars": [5, 4, 4, 2, 3, 1],
}
)
app = FastAPI()
MEDIA_TYPES = {"png": "image/png", "svg": "image/svg+xml"}
@app.get(
"/charts/stars.{fmt}",
response_class=Response,
responses={200: {"content": {"image/png": {}, "image/svg+xml": {}}, "description": "The chart image"}},
)
def stars_chart(
fmt: Literal["png", "svg"],
width: Annotated[int, Query(ge=200, le=1600)] = 640,
height: Annotated[int, Query(ge=150, le=1200)] = 400,
) -> Response:
counts = REVIEWS.groupby("book", as_index=False)["stars"].mean()
fig = px.bar(counts, x="book", y="stars", title="Mean stars per book", range_y=[0, 5])
image = fig.to_image(format=fmt, width=width, height=height)
return Response(
content=image,
media_type=MEDIA_TYPES[fmt],
headers={"Cache-Control": "public, max-age=300"},
)
The fmt path parameter is a Literal, so anything other than png or svg is rejected with a 422 before any work
happens, and width and height are bounded with Query constraints so that nobody can request a
100000-pixel-wide image. Returning a Response bypasses the response model, so the responses argument documents the
two media types for OpenAPI; the generated 200 entry (taken from the running application) is
{"description": "The chart image", "content": {"image/png": {}, "image/svg+xml": {}}}
The run printed 200 image/png public, max-age=300 and a body that starts
with the PNG signature for .png, 200 image/svg+xml and a body that starts with <svg for .svg.
The Cache-Control header is worth the line: the same chart for the same data does not need to be rendered again
by every browser. When the data changes rarely, cache the rendered bytes in the application as well.
Letting the Browser Draw the Chart
Static images suit emails, reports and <img> tags. For interactive charts, send the figure’s JSON and let Plotly.js
draw it in the browser; the server then does no rendering and needs no browser engine:
import plotly.express as px
from fastapi import Response
from plotly_app import REVIEWS, app # the application of the previous example
@app.get("/charts/stars-figure")
def stars_figure() -> Response:
counts = REVIEWS.groupby("book", as_index=False)["stars"].mean()
fig = px.bar(counts, x="book", y="stars", title="Mean stars per book")
return Response(content=fig.to_json(), media_type="application/json")
Figure.to_json() already returns a string, so the example returns it in a Response: converting it with
json.loads just to let FastAPI serialize it again would be wasted work. The result has the two top-level keys
data and layout that Plotly.js expects (the run returned 200 application/json, a bar trace and the title
"Mean stars per book"). (The route is /charts/stars-figure, not
/charts/stars.json, because /charts/stars.{fmt} above would otherwise capture it first.)
A Hugging Face Model, Loaded Once
Serving a model has one rule that everything else follows from: loading is slow and memory-hungry, using is comparatively cheap, so load once per process, at startup. Loading in the path operation repeats the work on every request and, under load, loads several copies at once and runs out of memory. Loading in a module-level global at import time slows every import, including the one your tests do. The lifespan is the right place: it runs once before the first request, its state is available to every request, and the server does not accept traffic until it has finished.
The example uses a small helper class that owns the tokenizer and the model. It lives in its own module so that
later examples can import it. It loads sshleifer/tiny-gpt2, a very small model published on the Hugging Face Hub,
chosen only so that the example downloads and runs in seconds; its output is meaningless words. The model identifier
is read from the environment, so replacing it with a real model changes no code.
import os
import threading
from collections.abc import Iterator
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
MODEL_ID = os.environ.get("MODEL_ID", "sshleifer/tiny-gpt2")
class TextGenerator:
"""Owns a tokenizer and a model; both are loaded once."""
def __init__(self, model_id: str) -> None:
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
self.model = AutoModelForCausalLM.from_pretrained(model_id)
self.model.eval()
self._lock = threading.Lock() # one generation at a time on this model
def _kwargs(self, prompt: str, max_new_tokens: int) -> dict:
inputs = self.tokenizer(prompt, return_tensors="pt")
return dict(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=self.tokenizer.eos_token_id,
)
def generate(self, prompt: str, max_new_tokens: int) -> str:
kwargs = self._kwargs(prompt, max_new_tokens)
with self._lock:
output = self.model.generate(**kwargs)
new_tokens = output[0][kwargs["input_ids"].shape[1]:]
return self.tokenizer.decode(new_tokens, skip_special_tokens=True)
def stream(self, prompt: str, max_new_tokens: int) -> Iterator[str]:
kwargs = self._kwargs(prompt, max_new_tokens)
streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, timeout=30.0)
def run() -> None:
with self._lock:
self.model.generate(**kwargs, streamer=streamer)
worker = threading.Thread(target=run, daemon=True)
worker.start()
try:
yield from streamer
finally:
worker.join()
generate is the blocking, all-at-once call. stream starts the same generation in a separate thread and yields
text pieces as TextIteratorStreamer receives them (skip_prompt=True leaves the prompt out of the output, and
timeout makes a stuck generation fail with an error instead of hanging the response). That separate thread is what
transformers documents for this streamer: generation blocks until it ends, so something else must read the pieces
while it runs. The lock allows one generation at a time on the shared model, which is a deliberately simple policy;
production inference servers batch concurrent requests instead, and that is where tools such as those in the
Hugging Face section take over.
The Lifespan, a Dependency and Three Endpoints
The application loads one TextGenerator in the lifespan, yields it as lifespan state, and exposes it through a
dependency. The three endpoints share it: a plain call, a Server-Sent Events stream and a JSON Lines stream.
import json
from collections.abc import Iterable
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, Request
from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from generation import MODEL_ID, TextGenerator
@asynccontextmanager
async def lifespan(app: FastAPI):
yield {"generator": TextGenerator(MODEL_ID)}
def get_generator(request: Request) -> TextGenerator:
return request.state.generator
Generator = Annotated[TextGenerator, Depends(get_generator)]
app = FastAPI(lifespan=lifespan)
class SummaryRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=2000)
max_new_tokens: int = Field(default=16, ge=1, le=64)
class Summary(BaseModel):
text: str
class Token(BaseModel):
text: str
@app.post("/summaries")
def summarize(body: SummaryRequest, generator: Generator) -> Summary:
return Summary(text=generator.generate(body.prompt, body.max_new_tokens))
@app.post("/summaries/stream", response_class=EventSourceResponse)
def summarize_stream(body: SummaryRequest, generator: Generator) -> Iterable[ServerSentEvent]:
for piece in generator.stream(body.prompt, body.max_new_tokens):
yield ServerSentEvent(data=Token(text=piece), event="token")
yield ServerSentEvent(raw_data="[DONE]", event="done")
@app.post("/summaries/lines")
def summarize_lines(body: SummaryRequest, generator: Generator) -> Iterable[Token]:
for piece in generator.stream(body.prompt, body.max_new_tokens):
yield Token(text=piece)
Source: FastAPI: Lifespan Events
All three path operations are def, so each request runs in a threadpool thread, and the blocking generate call does
not stop the event loop. A def generator is also run in the threadpool, one step per yielded item, which is exactly
what the streaming endpoints need: the thread waits on the streamer while the event loop sends the pieces out.
The model is reached through request.state, the object FastAPI exposes for lifespan state, wrapped in a one-line
dependency (see Dependency Injection); the request and response bodies
are Pydantic models, so a prompt that is empty or a max_new_tokens above the bound is a 422 and never reaches the model.
The diagram follows one streamed request: the event loop never runs the model, a threadpool thread
reads the streamer, and a second thread runs generate.
Streaming the Tokens
The requests below were sent to this application (output from the run, with the model’s meaningless words):
curl -N -X POST http://127.0.0.1:8000/summaries/stream \
-H 'Content-Type: application/json' \
-d '{"prompt": "Dune is", "max_new_tokens": 5}'
event: token
data: {"text":" "}
event: token
data: {"text":"factors "}
...
event: token
data: {"text":"factors"}
event: done
data: [DONE]
Each token event carries a small JSON document, data is always JSON-encoded when you pass it as data, and the
done event is sent with raw_data so that its payload is the bare marker [DONE] that many chat clients look for. The
JSON Lines endpoint returns the same pieces as one document per line with Content-Type: application/jsonl:
{"text":" "}
{"text":"factors "}
...
{"text":"factors"}
Which format to choose: Server-Sent Events when the consumer is a browser (EventSource parses the stream, reconnects
and exposes event types; note that EventSource only issues GET requests, so a POST like the one above needs
fetch and a manual reader), JSON Lines when the consumer is a program that reads line by line. The concatenated pieces
equal the text of the non-streaming /summaries endpoint, which the run confirmed. Resuming, disconnect handling, proxy
buffering and the reasons not to send a status code after the first byte are on
Streaming, JSON Lines & SSE.
A Worker Process Instead of a Thread
Threads share the GIL. Tensor libraries release it while they compute, so threads are usually fine for inference, but
code that is pure Python and CPU-heavy (tokenisation of huge inputs, feature engineering, a model written in Python)
does not get faster with more threads. When the work is that kind, or when the model should live outside the web process
so that it can be restarted or sized independently, run it in a worker process and await the result from an
async def endpoint. The standard library’s ProcessPoolExecutor is the smallest version of this; each worker loads its
own model once through the initializer argument.
import asyncio
from concurrent.futures import ProcessPoolExecutor
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel
from generation import MODEL_ID, TextGenerator
_generator: TextGenerator | None = None # one copy per worker process
def init_worker() -> None:
global _generator
_generator = TextGenerator(MODEL_ID)
def generate_in_worker(prompt: str, max_new_tokens: int) -> str:
assert _generator is not None
return _generator.generate(prompt, max_new_tokens)
@asynccontextmanager
async def lifespan(app: FastAPI):
with ProcessPoolExecutor(max_workers=1, initializer=init_worker) as pool:
yield {"pool": pool}
app = FastAPI(lifespan=lifespan)
class Summary(BaseModel):
text: str
@app.post("/summaries/process")
async def summarize_in_process(prompt: str, request: Request) -> Summary:
loop = asyncio.get_running_loop()
text = await loop.run_in_executor(request.state.pool, generate_in_worker, prompt, 5)
return Summary(text=text)
Source: FastAPI: Lifespan Events
run_in_executor turns the blocking call into something the endpoint can await, so the event loop is never occupied and
the endpoint is async def. Arguments and return values travel between processes by pickling, so pass simple values
(strings, numbers, plain data) and never the model itself; the worker function and the initializer must be importable
at module level, which is why the generator class lives in its own module. Each worker process holds its own copy of
the model; with several workers, and with several Uvicorn processes in front (see
Deployment), memory use multiplies, so size the pool deliberately. The run
printed the same five words as the thread-based endpoint. Beyond one machine, the next step is a separate model server
or a task queue that the API calls over the network, which is the territory of the LangChain and
Hugging Face sections.
Testing Without Downloading a Model
A test suite should not download weights. Because the endpoints get the generator through a dependency, a test replaces
it with a fake that has the same two methods, and does not enter the TestClient context manager so that the lifespan
(and therefore the model load) does not run:
from collections.abc import Iterator
from fastapi.testclient import TestClient
from hf_app import app, get_generator
class FakeGenerator:
def generate(self, prompt: str, max_new_tokens: int) -> str:
return "a fake summary"
def stream(self, prompt: str, max_new_tokens: int) -> Iterator[str]:
yield from ["a ", "fake ", "summary"]
app.dependency_overrides[get_generator] = lambda: FakeGenerator()
client = TestClient(app) # no "with": the lifespan, and the model download, do not run
def test_summary():
r = client.post("/summaries", json={"prompt": "Dune is"})
assert r.json() == {"text": "a fake summary"}
def test_stream():
r = client.post("/summaries/stream", json={"prompt": "Dune is"})
assert r.text.count("event: token") == 3
assert r.text.rstrip().endswith("data: [DONE]")
Both tests passed in about two seconds, most of it importing transformers; the model was never loaded. Keep
one slow test that loads a real (tiny) model through the lifespan, marked so that it runs in CI but not on every save;
the general testing approach, including the TestClient context manager and its lifespan behaviour, is on
Testing.
A Vector-Search Endpoint
Semantic search compares the meaning of a query with the meaning of stored texts by comparing embeddings: vectors in which similar texts are close. The concepts are on this site under Embeddings and Similarity Metrics and Semantic Search. At production scale the vectors live in a vector store such as Qdrant or in PostgreSQL with pgvector, and the endpoint is a thin layer over it; the Bookshelf endpoints for that are in A Vector Store for Semantic Search over Reviews.
For a few thousand vectors, or while prototyping, an in-memory index is enough and shows every moving part without a
server. The index below is a NumPy matrix with one unit-length row per review: for unit vectors, a dot product is the
cosine similarity, so a single matrix-vector product scores every review, and argsort ranks them. The index is built
in the lifespan, as the model was, and reached through a dependency.
The embedder here is a stand-in. It hashes words into a fixed-size vector, so it matches shared words, not synonyms; a
real embedding model (for example one loaded as in the previous section) is a drop-in replacement, because the rest of
the code only relies on the embed method. The honest consequence is that the scores below show the mechanics of the
endpoint, not the quality of a semantic search.
import hashlib
import re
from contextlib import asynccontextmanager
from typing import Annotated, Protocol
import numpy as np
from fastapi import Depends, FastAPI, Request
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
DIM = 256
class Embedder(Protocol):
def embed(self, texts: list[str]) -> np.ndarray: ...
class HashingEmbedder:
"""A stand-in with the interface of a real embedding model.
It hashes words into a fixed-size vector, so it matches shared words, not meaning.
Replace it with a real embedding model in production; nothing else changes.
"""
def embed(self, texts: list[str]) -> np.ndarray:
out = np.zeros((len(texts), DIM), dtype=np.float32)
for row, text in enumerate(texts):
for word in re.findall(r"[a-z']+", text.lower()):
bucket = int.from_bytes(hashlib.sha256(word.encode()).digest()[:4], "big") % DIM
out[row, bucket] += 1.0
norms = np.linalg.norm(out, axis=1, keepdims=True)
return out / np.maximum(norms, 1e-9) # unit length: dot product == cosine similarity
REVIEWS = [
{"id": 1, "book_id": 1, "text": "A slow and political start that rewards patience"},
{"id": 2, "book_id": 1, "text": "Gripping desert adventure with a political edge"},
{"id": 3, "book_id": 2, "text": "A witty comedy of manners"},
{"id": 4, "book_id": 3, "text": "Dense and difficult, but a political and literary landmark"},
]
class Index:
def __init__(self, embedder: Embedder, reviews: list[dict]) -> None:
self.embedder = embedder
self.reviews = reviews
self.matrix = embedder.embed([r["text"] for r in reviews]) # shape (n, DIM)
def search(self, query: str, limit: int) -> list[tuple[dict, float]]:
vector = self.embedder.embed([query])[0]
scores = self.matrix @ vector
best = np.argsort(-scores)[:limit]
return [(self.reviews[i], float(scores[i])) for i in best]
@asynccontextmanager
async def lifespan(app: FastAPI):
yield {"index": Index(HashingEmbedder(), REVIEWS)}
def get_index(request: Request) -> Index:
return request.state.index
app = FastAPI(lifespan=lifespan)
class SearchRequest(BaseModel):
query: str = Field(min_length=1, max_length=500)
limit: int = Field(default=3, ge=1, le=20)
class Hit(BaseModel):
review_id: int
book_id: int
text: str
score: float
@app.post("/reviews/search")
def search_reviews(body: SearchRequest, index: Annotated[Index, Depends(get_index)]) -> list[Hit]:
return [
Hit(review_id=r["id"], book_id=r["book_id"], text=r["text"], score=round(score, 3))
for r, score in index.search(body.query, body.limit)
]
Source: FastAPI: Lifespan Events
Calling it for the word "political" returns the two review texts that contain it, best first:
curl -X POST http://127.0.0.1:8000/reviews/search \
-H 'Content-Type: application/json' \
-d '{"query": "political", "limit": 2}'
[
{"review_id": 2, "book_id": 1, "text": "Gripping desert adventure with a political edge", "score": 0.378},
{"review_id": 1, "book_id": 1, "text": "A slow and political start that rewards patience", "score": 0.354}
]
The endpoint is a def because the search is a blocking NumPy call. An empty query is rejected with 422, and
limit is bounded so that a request cannot ask for the whole collection. To move to a real engine, keep the endpoint,
the request and response models and the dependency, and replace Index with a class that calls the vector store’s
async client from the lifespan-managed connection shown on the NoSQL page; with a remote store the call becomes
non-blocking and the endpoint becomes async def. Building a full retrieval-augmented generation pipeline on top of
the search results (retrieve, then prompt a model with them) combines this section with the previous one and is the
subject of the LangChain section.
Where Older Guides Are Outdated, and What Changed Recently
-
Models in globals and startup events. Older guides load a model at import time or in
@app.on_event("startup"). Lifespan context managers replaced the startup and shutdown events in 0.93.0 (2023-03-07), and lifespan state exposed throughrequest.statereplaces the global variable. -
Blocking calls in
async def. Examples that runpipeline(…),read_csvor a chart export directly inside anasync defhandler block every other request. Usedef, orrun_in_executorwith a process pool, as above. -
Hand-written event streams. Streaming tokens used to mean a
StreamingResponsewithtext/event-streamwritten by hand or a third-party package. FastAPI 0.135.0 (2026-03-01) addedEventSourceResponseandServerSentEvent, and 0.134.0 (2026-02-27) added JSON Lines streaming byyield. -
Returning NumPy and pandas objects. Pydantic v2 response models and return annotations (return annotations as the response model since 0.89.0) are the clean boundary; the v1-era
.dict()and custom JSON encoders are not needed. -
Chart export. Static image export in Plotly now goes through Kaleido 1.x, which needs a Chrome or Chromium installed on the machine instead of bundling one.
-
Python versions. FastAPI 0.142.2 needs Python 3.10 or newer; machine-learning stacks follow their own support matrix, so check the library before choosing the interpreter for the image.
-
Release history. See What’s Changed & Migration.
Pitfalls
-
Loading the model per request. Slow, and several copies in memory at once under load. Load in the lifespan.
-
Calling the model from
async def. It blocks the event loop for the duration of the inference. Usedefor a worker process. -
An unbounded request. Limit prompt length,
max_new_tokens, image sizes, upload sizes and result counts with Pydantic constraints andQuerybounds, because each one is a way to make one request cost a minute of CPU. -
Threadpool exhaustion. Every request waiting for the model’s lock occupies a threadpool thread (40 by default; see the tuning section of the async page). Cap concurrency deliberately and return
503or queue work rather than letting requests pile up. -
Memory with several workers. Each Uvicorn worker process and each pool worker loads its own copy of the model. Multiply before you deploy.
-
NaNand NumPy types in JSON. ANaNcannot be serialized (the run of the unguarded version fails withOut of range float values are not JSON compliant: nan); convert toNoneand to plain Python numbers through a response model. -
Mutating a shared frame or index. Threads share it. Treat it as read-only and swap in a new object.
-
Streaming and status codes. Once the first token is sent, the status is
200; a model error mid-stream ends the stream. Validate the request first and send an error event or a final line that describes the failure. -
Generation that outlives the client. If the client disconnects, the generator is closed but the generation thread of the example runs until
max_new_tokens; keep that bound small, or make the generation loop stop on a flag. -
Tests that download weights. Override the dependency with a fake and keep one marked integration test.
-
No Chrome in the container. Kaleido 1.x finds a browser on the machine; a slim image without one fails on the first chart request.
-
Secrets. A token for a private model on the Hub, or an API key for an embedding service, belongs in the environment or in a secret file (see Settings, Lifespan & Background Tasks); never in the source.
What Was Run
Every code block above was executed on Python 3.13 with TestClient and produced the outputs shown. The model
examples ran against the real sshleifer/tiny-gpt2 weights downloaded from the Hugging Face Hub (not a stub), with the
thread-based endpoints and the process-pool endpoint all returning the same greedy-decoded text, and the fake-generator
tests passed. The vector-search example uses a hashing stand-in as its embedder because no embedding model was
downloaded for it; it therefore demonstrates the endpoint and the index, not semantic quality. Nothing here was
load-tested: the figures in the pitfalls about threads and memory are properties of the design, not benchmarks.