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?

Table 1. Where data-science work should run
Work Where it runs Why

A pandas aggregation over a small in-memory frame, a NumPy matrix product

A plain def path operation (threadpool)

The call blocks while it computes; def keeps the event loop free.

Rendering a chart image (Plotly with Kaleido)

A plain def path operation

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 def path operation, with the model loaded once in the lifespan

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 ProcessPoolExecutor or a separate worker service, awaited from async def

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.

A response model between pandas and JSON
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.

One endpoint, two image formats
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.

The generator class: tokenizer and model, plus a token iterator
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.

Lifespan state, a dependency and three ways to return the same text
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)

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.

sequenceDiagram autonumber participant C as Client participant L as Event loop participant T as Threadpool thread participant G as Generation thread Note over L: Lifespan already loaded the model C->>L: POST /summaries/stream L->>T: run the def generator T->>G: start model.generate(streamer=...) loop each token G-->>T: piece of text via the streamer T-->>L: yield ServerSentEvent L-->>C: event: token end G-->>T: generation finished T-->>L: yield done event L-->>C: event: done

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.

A process pool created in the lifespan
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)

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:

Overriding the generator in a test
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.

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.

An in-memory vector index behind a search endpoint
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)
    ]

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 through request.state replaces the global variable.

  • Blocking calls in async def. Examples that run pipeline(…​), read_csv or a chart export directly inside an async def handler block every other request. Use def, or run_in_executor with a process pool, as above.

  • Hand-written event streams. Streaming tokens used to mean a StreamingResponse with text/event-stream written by hand or a third-party package. FastAPI 0.135.0 (2026-03-01) added EventSourceResponse and ServerSentEvent, and 0.134.0 (2026-02-27) added JSON Lines streaming by yield.

  • 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. Use def or a worker process.

  • An unbounded request. Limit prompt length, max_new_tokens, image sizes, upload sizes and result counts with Pydantic constraints and Query bounds, 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 503 or 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.

  • NaN and NumPy types in JSON. A NaN cannot be serialized (the run of the unguarded version fails with Out of range float values are not JSON compliant: nan); convert to None and 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.