What’s Changed & Migration

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. FastAPI supports Python 3.10 through 3.14; every current-style Python example on this page was executed on Python 3.13 with TestClient or plain execution, and every deprecated or removed form is shown only as the old way and was not run as a recommendation. FastAPI is still a 0.x project, so almost every release can contain a visible change, and a large part of the material on the web (tutorials, blog posts, and the reference book listed in the section bibliography) was written before many of those releases. This page is the map between that older material and today’s documentation: which release changed what, on which date, what the old code looked like, what to write now, which page of this section covers it, and where the official release notes say it.

How to Read the Tables

Each row has the FastAPI version that introduced the change, the release date, the old way you will meet in older code, the new way, a link to the page of this section that covers it, and a link to the official release notes. The version links use the anchor form of the release notes page, which is the version without dots followed by the release date. Where a change belongs to a library or to Python rather than to a numbered FastAPI release, the version column says so and the last column links the official page that documents it instead.

Two rules of thumb make migrations safe. Pin FastAPI to an exact version in pyproject.toml or in a lock file, as the official versions page advises for a 0.x project, and read the release notes entries marked as breaking before every upgrade. Run the test suite (it prints a deprecation warning from Starlette about httpx and httpx2 with the current TestClient; see Testing) after each upgrade: most of the changes below fail loudly at import time or on the first request, not silently in production.

The Dated Baseline

Table 1. Versions this section is written against (checked 2026-10-02)
Component Version What changed to get here

FastAPI

0.142.2 (2026-09-30)

Still 0.x. The latest entries are 0.142.0 (2026-09-29, native OpenTelemetry), 0.142.1 (fixes repeated wrapping of endpoints in included routers) and 0.142.2 (startup no longer fails if automatic OpenTelemetry configuration fails).

Python

3.10 to 3.14

3.8 was dropped in 0.125.0 (2025-12-17) and 3.9 in 0.129.0 (2026-02-12). Python 3.14 is supported since 0.118.3 (2025-10-10) and the free-threaded build 3.14t since 0.136.0 (2026-04-16).

Pydantic

2.13, version 2 only

FastAPI requires Pydantic 2.9 or newer. Pydantic v1 support was removed in 0.126.0 and the pydantic.v1 shim in 0.128.0.

Starlette

1.7, 0.46 or newer accepted

Starlette 1.x is supported since 0.133.0 (2026-02-24).

Install command

pip install "fastapi[standard]"

Plain pip install fastapi no longer installs the standard dependencies (0.112.0), and the fastapi-slim package was deprecated and then dropped (0.128.8 and 0.129.2).

Installing, Running and Deploying

Table 2. Tooling and deployment changes
Version Date Old way New way Covered on Release notes

0.111.0

2024-05-03

uvicorn main:app --reload, or uvicorn.run(…​) in a main block

fastapi dev for development and fastapi run for production (declaring the entrypoint in pyproject.toml was documented later, in 0.135.2, 2026-03-01)

Getting Started

0.111.0

0.112.0

2024-08-02

pip install fastapi pulled in the standard dependencies

pip install "fastapi[standard]", or uv add "fastapi[standard]"; python -m fastapi also works

Getting Started

0.112.0

0.128.8, 0.129.2

2026-02-11, 2026-02-21

pip install fastapi-slim

fastapi (or fastapi[standard]); fastapi-slim is deprecated and receives no more versions

Getting Started

0.128.8 and 0.129.2

Not tied to a FastAPI release

n/a

gunicorn -k uvicorn.workers.UvicornWorker; one container running several workers

fastapi run --workers N or uvicorn --workers N; the separate uvicorn-worker package if Gunicorn is wanted; one process per container under Kubernetes

Deployment and Docker & Kubernetes

Server Workers

0.93.0

2023-03-07

@app.on_event("startup") and @app.on_event("shutdown")

a lifespan async context manager passed to FastAPI(lifespan=…​)

Settings, Lifespan & Background Tasks

0.93.0

0.116.0

2025-07-07

Hand-built deployment pipelines only

fastapi deploy to FastAPI Cloud, next to the container and server options

Deployment

0.116.0

0.125.0, 0.129.0

2025-12-17, 2026-02-12

Python 3.8 and 3.9 supported

Python 3.10 or newer

Getting Started

0.125.0 and 0.129.0

0.118.3, 0.136.0

2025-10-10, 2026-04-16

Python 3.13 as the newest tested interpreter

Python 3.14, and the free-threaded 3.14t build

Async & Concurrency

0.118.3 and 0.136.0

0.140.0

2026-07-24

No agent guidance in the docs

The FastAPI Agent Skill ("Library Skills") for coding agents

Getting Started

0.140.0

Not tied to a FastAPI release

n/a

Editor setup left to the reader

The official editor extension described in the docs

Getting Started

Editor Support

The old running style still works, because Uvicorn is a normal ASGI server. What changed is the documented default. Server options that the fastapi command does not expose are reached through Uvicorn directly, as the Deployment page shows.

# Old way (still valid, but no longer the documented default)
uvicorn main:app --reload

# New way
uv add "fastapi[standard]"
fastapi dev            # auto-reload, development
fastapi run --workers 4   # production

Startup and Shutdown: From on_event to lifespan

Older code registers startup and shutdown handlers with a decorator that is now deprecated:

Old way (deprecated, not recommended; not run)
from fastapi import FastAPI

app = FastAPI()
resources: dict[str, str] = {}


@app.on_event("startup")
async def startup() -> None:
    resources["db"] = "connected"


@app.on_event("shutdown")
async def shutdown() -> None:
    resources.clear()

The replacement is one async context manager: everything before yield is startup, everything after is shutdown, and the two halves can share local variables. The with TestClient(app) block runs the lifespan; without the with, it does not run.

Current: lifespan context manager
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.testclient import TestClient

resources: dict[str, str] = {}


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    resources["db"] = "connected"      # startup
    yield
    resources.clear()                  # shutdown


app = FastAPI(lifespan=lifespan)


@app.get("/status")
def status() -> dict[str, str]:
    return resources


with TestClient(app) as client:
    print(client.get("/status").json())
print("after shutdown:", resources)
{'db': 'connected'}
after shutdown: {}

Python Types and Pydantic v2

Table 3. Python typing and Pydantic changes
Version Date Old way New way Covered on Release notes

0.95.0

2023-03-18

q: str = Query(default=None, max_length=50) and Depends() as default values

q: Annotated[str | None, Query(max_length=50)] = None; reusable aliases such as Annotated[User, Depends(get_user)]

Python Types & Annotated

0.95.0

0.126.0

2025-12-20

Pydantic v1 models and a pydantic.v1 compatibility layer

Pydantic v2 only; the v1 compatibility path is gone

Pydantic v2 Essentials

0.126.0

0.128.0

2025-12-27

from pydantic.v1 import BaseModel inside a FastAPI app

from pydantic import BaseModel; use the official migration guide and bump-pydantic for existing code

Pydantic v2 Essentials

0.128.0

0.128.2

2026-02-05

Type aliases written as plain assignments

PEP 695 type X = Annotated[…​] aliases (Python 3.12 and later) accepted as TypeAliasType

Python Types & Annotated

0.128.2

The Pydantic v1 to v2 Replacements

These are the replacements to apply to code written for Pydantic v1. They come from the Pydantic migration guide and from the FastAPI documentation, and they are shown in full on Pydantic v2 Essentials.

Table 4. Pydantic v1 to v2 (FastAPI 0.126.0 and later accept only v2)
Pydantic v1 (old way) Pydantic v2 (new way) Notes

.dict()

model_dump()

.json() becomes model_dump_json(); .parse_obj() becomes model_validate()

conint(…​), constr(regex=…​, to_upper=…​)

Annotated[int, Field(ge=1)], Annotated[str, Field(pattern=…​)], StringConstraints(…​)

regex is now pattern

min_items, max_items

min_length, max_length

The same names apply to lists, strings and sets

Field(…​) to mark a field as required

No default at all

A field with no default is required

class Config: with orm_mode = True

model_config = ConfigDict(from_attributes=True)

Class-based Config is deprecated

@validator, @root_validator

@field_validator, @model_validator

Validators are classmethods in v2

Optional[X] without a default looked optional

Still required in v2: X | None without a default must be sent

Add = None to make it optional

Error type such as value_error.missing

Error type such as missing

Error payloads also carry input and loc

Models accepted positional arguments in some cases

Keyword-only construction

Book(title="Dune"), never Book("Dune")

Old way (Pydantic v1, removed from FastAPI; not run)
from pydantic import BaseModel, Field, validator


class Book(BaseModel):
    title: str = Field(..., min_items=1)
    tags: list[str] = Field(default_factory=list, min_items=0)

    class Config:
        orm_mode = True

    @validator("title")
    def strip_title(cls, value):
        return value.strip()


payload = Book(title=" Dune ").dict()

The same model in Pydantic v2 style. The script prints the dump, the new error type for a missing field, and shows that positional construction is rejected.

Current: Pydantic v2 equivalents
from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator


class Book(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    title: Annotated[str, Field(min_length=1, pattern=r"^\S.*$")]
    tags: Annotated[list[str], Field(max_length=5)] = []
    subtitle: str | None            # no default: still required in v2

    @field_validator("title")
    @classmethod
    def strip_title(cls, value: str) -> str:
        return value.strip()


class Row:
    title = "Dune"
    tags: list[str] = []
    subtitle = None


print(Book(title="Dune", subtitle=None).model_dump())
print(Book.model_validate(Row()).title)

try:
    Book(title="Dune")
except ValidationError as exc:
    print(exc.errors()[0]["type"], exc.errors()[0]["loc"])

try:
    Book("Dune")                    # type: ignore[misc]
except TypeError as exc:
    print("positional:", type(exc).__name__)
{'title': 'Dune', 'tags': [], 'subtitle': None}
Dune
missing ('subtitle',)
positional: TypeError

Annotated Instead of Default-Value Metadata

The older style is still accepted, so you will meet it in existing code. New code uses Annotated, because the default value stays a real default (and works for plain Python calls) and the metadata can be shared through an alias.

Old way (still accepted, not recommended; not run)
from fastapi import Depends, FastAPI, Query

app = FastAPI()


def pagination(limit: int = Query(default=10, ge=1, le=100)) -> int:
    return limit


@app.get("/books")
def list_books(q: str | None = Query(default=None, max_length=50), limit: int = Depends(pagination)):
    return {"q": q, "limit": limit}
Current: Annotated with a reusable alias
from typing import Annotated

from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient

app = FastAPI()


def pagination(limit: Annotated[int, Query(ge=1, le=100)] = 10) -> int:
    return limit


Limit = Annotated[int, Depends(pagination)]       # reusable alias


@app.get("/books")
def list_books(limit: Limit, q: Annotated[str | None, Query(max_length=50)] = None) -> dict:
    return {"q": q, "limit": limit}


client = TestClient(app)
print(client.get("/books", params={"q": "dune", "limit": 5}).json())
print(client.get("/books", params={"limit": 500}).status_code)
{'q': 'dune', 'limit': 5}
422
Current: a PEP 695 type alias (0.128.2, Python 3.12 or later)
from typing import Annotated

from fastapi import FastAPI, Query
from fastapi.testclient import TestClient

type PageSize = Annotated[int, Query(ge=1, le=100)]

app = FastAPI()


@app.get("/books")
def list_books(size: PageSize = 20) -> dict[str, int]:
    return {"size": size}


client = TestClient(app)
print(client.get("/books", params={"size": 7}).json(), client.get("/books", params={"size": 0}).status_code)
{'size': 7} 422

Security Libraries and Status Codes

Table 5. Security changes
Version Date Old way New way Covered on Release notes

0.111.1

2024-07-14

python-jose for JWT tokens

PyJWT (import jwt)

OAuth2 Password Flow & JWT

0.111.1

0.118.0

2025-09-29

passlib with bcrypt for password hashes

pwdlib with Argon2 (PasswordHash.recommended())

OAuth2 Password Flow & JWT

0.118.0

Python 3.12

n/a

datetime.utcnow() for token expiry

datetime.now(timezone.utc), a timezone-aware value

OAuth2 Password Flow & JWT

OAuth2 with Password (and hashing), Bearer with JWT tokens

0.122.0

2025-11-24

Missing credentials answered with 403 Forbidden by the security classes

401 Unauthorized (with a WWW-Authenticate header); a how-to restores 403 for clients that depend on it

Security Fundamentals and OAuth2 Scopes & Authorization

0.122.0

A related misunderstanding in older material is treating the password flow and the authorization code flow as one thing. They are different OAuth2 flows: the password flow sends credentials to your own token endpoint, while the authorization code flow redirects the user to an identity provider. The current form of the password flow is shown below.

Old way (python-jose, passlib, utcnow; not run)
import os
from datetime import datetime, timedelta

from jose import jwt
from passlib.context import CryptContext

SECRET_KEY = os.environ["SECRET_KEY"]          # never hardcode a key
pwd = CryptContext(schemes=["bcrypt"])
token = jwt.encode({"sub": "ana", "exp": datetime.utcnow() + timedelta(minutes=30)}, SECRET_KEY)

The current example reads the signing key from the environment (generate one with openssl rand -hex 32 and keep it in your secret store), uses PyJWT and pwdlib, and shows that a missing token now answers 401.

Current: PyJWT, pwdlib, timezone-aware expiry, 401 without credentials
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI
from fastapi.security import OAuth2PasswordBearer
from fastapi.testclient import TestClient
from pwdlib import PasswordHash

# In a real app this comes from settings (openssl rand -hex 32); a random value keeps the demo self-contained.
SECRET_KEY = os.environ.get("BOOKSHELF_SECRET_KEY") or secrets.token_hex(32)
ALGORITHM = "HS256"

password_hash = PasswordHash.recommended()          # Argon2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()


def create_token(subject: str) -> str:
    expire = datetime.now(timezone.utc) + timedelta(minutes=30)
    return jwt.encode({"sub": subject, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)


@app.get("/me")
def me(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, str]:
    payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    return {"user": payload["sub"]}


hashed = password_hash.hash("a-test-password")
print(password_hash.verify("a-test-password", hashed))

client = TestClient(app)
print(client.get("/me").status_code)
print(client.get("/me", headers={"Authorization": f"Bearer {create_token('ana')}"}).json())
True
401
{'user': 'ana'}

Data Access, Settings and Dependencies

The older way to reach a database was a module-global connection, with environment variables choosing between a fake and a real module. The documented way is a session dependency with yield over SQLModel (the SQL tutorial was rewritten in 0.115.1), configuration through pydantic-settings, and test doubles through app.dependency_overrides.

Table 6. Data access and dependency changes
Version Date Old way New way Covered on Release notes

0.115.1

2024-10-12

Module-global sqlite3 connection; fake and real modules chosen by an environment variable

SQLModel with a session dependency that uses yield; SQLAlchemy 2.0 underneath

SQL Databases with SQLModel

0.115.1

Not tied to a FastAPI release

n/a

Reading os.environ all over the code

BaseSettings from pydantic-settings, in a cached settings dependency

Settings, Lifespan & Background Tasks

Settings and Environment Variables

Not tied to a FastAPI release

n/a

Swapping a module, or monkeypatching, to replace a dependency in tests

app.dependency_overrides[get_dependency] = fake

Testing and Dependency Injection

Testing Dependencies with Overrides

0.106.0

2023-12-25

Exit code of a yield dependency could not change the response; breaking: yielded resources could no longer be used in background tasks

HTTPException can be raised in the exit code after yield; open resources in the task instead

Dependencies with yield

0.106.0

0.118.0

2025-09-29

Exit code ran before the response was sent (the behavior introduced by 0.106.0)

Exit code runs after the response has been sent, as it did before 0.106.0

Dependencies with yield

0.118.0

0.121.0

2025-11-03

The exit code of a yield dependency always ran at the end of the request

Depends(…​, scope="function") or scope="request" chooses when the exit code runs

Dependencies with yield

0.121.0

0.123.5

2025-12-02

A functools.partial object was not accepted as a dependency

functools.partial dependables (and wrapped dependencies) work

Dependency Injection

0.123.5

Current: settings, a yield dependency with scope, an override and a partial
from collections.abc import Iterator
from functools import cache, partial
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="BOOKSHELF_")
    database_url: str = "sqlite:///./bookshelf.db"


@cache
def get_settings() -> Settings:
    return Settings()


events: list[str] = []


def get_session(settings: Annotated[Settings, Depends(get_settings)]) -> Iterator[str]:
    events.append("open " + settings.database_url)
    try:
        yield "session"
    finally:
        events.append("close")


def greeting(prefix: str) -> str:
    return prefix + "!"


app = FastAPI()


@app.get("/books")
def books(session: Annotated[str, Depends(get_session, scope="function")]) -> dict[str, str]:
    return {"using": session}


@app.get("/hello")
def hello(text: Annotated[str, Depends(partial(greeting, "hello"))]) -> str:
    return text


client = TestClient(app)
print(client.get("/books").json(), events)
print(client.get("/hello").json())

app.dependency_overrides[get_session] = lambda: "fake-session"
print(client.get("/books").json())
{'using': 'session'} ['open sqlite:///./bookshelf.db', 'close']
hello!
{'using': 'fake-session'}

Testing and Templates

Older material often tests by calling path functions directly, which skips validation, dependencies and serialization. The documentation now tests through the application: TestClient, dependency_overrides, with TestClient(app) to run the lifespan, WebSocket sessions, and async tests with pytest.mark.anyio and httpx.AsyncClient over ASGITransport. The TestClient shown elsewhere on this page is the synchronous form; the async form is below. See Testing for the full treatment.

Current: async test client over ASGITransport
import anyio
import httpx
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root() -> dict[str, str]:
    return {"message": "Tomato"}


async def main() -> None:
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get("/")
    print(response.status_code, response.json())


anyio.run(main)
200 {'message': 'Tomato'}

Template rendering changed its call signature. Documentation updated to the new Starlette arguments in 0.108.0 (2023-12-26): the request is now the first argument and is no longer put into the context dictionary.

Old way (request inside the context; not run)
return templates.TemplateResponse("books.html", {"request": request, "books": books})
Current: TemplateResponse(request, name, context)
import tempfile
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.testclient import TestClient

directory = Path(tempfile.mkdtemp())
(directory / "books.html").write_text("<ul>{% for b in books %}<li>{{ b }}</li>{% endfor %}</ul>")

templates = Jinja2Templates(directory=directory)
app = FastAPI()


@app.get("/books", response_class=HTMLResponse)
def books(request: Request):
    return templates.TemplateResponse(request, "books.html", {"books": ["Dune", "Emma"]})


print(TestClient(app).get("/books").text)
<ul><li>Dune</li><li>Emma</li></ul>
Table 7. Testing and template changes
Version Date Old way New way Covered on Release notes

Not tied to a FastAPI release

n/a

Calling path functions directly in tests, bypassing validation and dependencies

TestClient, dependency_overrides, with TestClient(app) for lifespan, WebSocket and async clients

Testing

Testing

0.108.0

2023-12-26

TemplateResponse(name, {"request": request, …​})

TemplateResponse(request, name, context)

Static Files, Templates & Frontend

0.108.0

Forms and Parameters

Table 8. Form and parameter changes
Version Date Old way New way Covered on Release notes

0.113.0

2024-09-05

One Form() parameter per field

A Pydantic model for a form: Annotated[BookForm, Form()]

Forms & File Uploads

0.113.0

0.115.0

2024-09-17

One Query(), Header() or Cookie() parameter per value

A Pydantic model for a group of query, header or cookie parameters, with extra="forbid"

Query Parameters and Headers & Cookies

0.115.0

0.136.3

2026-05-23

Header names with underscores matched when convert_underscores=True

Such headers are no longer accepted when convert_underscores=True; send hyphenated header names

Headers & Cookies

0.136.3

A form is a POST body, never a GET: older tutorials sometimes declare Form() on a GET endpoint, which a browser form cannot meaningfully use. The current form models:

Current: Pydantic models for Form and Query parameters
from typing import Annotated, Literal

from fastapi import FastAPI, Form, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI()


class BookForm(BaseModel):
    model_config = {"extra": "forbid"}
    title: str
    year: int


class Filters(BaseModel):
    model_config = {"extra": "forbid"}
    limit: int = Field(100, gt=0, le=100)
    order_by: Literal["title", "year"] = "title"


@app.post("/books")
def create(form: Annotated[BookForm, Form()]) -> BookForm:
    return form


@app.get("/books")
def search(filters: Annotated[Filters, Query()]) -> Filters:
    return filters


client = TestClient(app)
print(client.post("/books", data={"title": "Dune", "year": "1965"}).json())
print(client.get("/books", params={"order_by": "year"}).json())
print(client.get("/books", params={"unknown": "x"}).status_code)
{'title': 'Dune', 'year': 1965}
{'limit': 100, 'order_by': 'year'}
422

Form models are documented on Form Models; the example above applies the same extra="forbid" rule to both.

Responses and OpenAPI

Table 9. Response and OpenAPI changes
Version Date Old way New way Covered on Release notes

0.89.0

2023-01-07

response_model=Book in the decorator

The return annotation declares the response model: def read_book() → Book

Response Models & Return Types

0.89.0

0.117.0

2025-09-20

A Response object returned by hand for an empty body

None is a valid return type for responses without a body

Response Models & Return Types

0.117.0

0.130.0

2026-02-22

Serialization performed in Python before the response was rendered

Pydantic (Rust) serializes JSON directly when a return type or response model exists

Response Models & Return Types and Performance & Observability

0.130.0

0.131.0

2026-02-22

response_class=ORJSONResponse or UJSONResponse for speed

A return type or response model; both classes are deprecated

Custom Responses

0.131.0

0.99.0

2023-06-30

OpenAPI 3.0.2 and JSON Schema draft 7

OpenAPI 3.1.0 and JSON Schema 2020-12, with webhooks and Swagger UI 5

OpenAPI & Interactive Docs

0.99.0

0.102.0

2023-08-25

One schema for both request and response

separate_input_output_schemas controls whether input and output schemas are split

OpenAPI & Interactive Docs

0.102.0

0.103.0

2023-08-26

example values in the schema

openapi_examples with named examples for parameters and bodies

OpenAPI & Interactive Docs

0.103.0

Old way (ORJSONResponse; deprecated since 0.131.0; not run)
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse

app = FastAPI(default_response_class=ORJSONResponse)
Current: return annotation, None for no body, OpenAPI 3.1
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class Book(BaseModel):
    title: str
    isbn: str | None = None


@app.get("/books/{book_id}")
def read_book(book_id: int) -> Book:                       # response model from the annotation
    return Book(title="Dune")


@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int) -> None:                     # no body
    return None


client = TestClient(app)
print(client.get("/books/1").json())
print(client.delete("/books/1").status_code, repr(client.delete("/books/1").content))
print(client.get("/openapi.json").json()["openapi"])
{'title': 'Dune', 'isbn': None}
204 b''
3.1.0

New Since Older Material

These entries have no old way to replace: older tutorials and the reference book do not mention them at all, or mention a third-party package that the framework now replaces.

Table 10. Features added since the older material was written
Version Date Old way New way Covered on Release notes

0.132.0

2026-02-23

JSON bodies parsed whatever the Content-Type

Strict Content-Type checking, a CSRF protection; breaking, disabled with strict_content_type=False

Request Object & Content-Type

0.132.0

0.133.0

2026-02-24

Starlette 0.x only

Starlette 1.0 and newer supported

Introduction & Architecture

0.133.0

0.134.0

2026-02-27

Hand-written streaming of one JSON document per line

yield from the path operation streams JSON Lines

Streaming, JSON Lines & SSE

0.134.0

0.135.0

2026-03-01

A third-party package (such as sse-starlette) or hand-written event framing

EventSourceResponse and ServerSentEvent from fastapi.sse, with Last-Event-ID

Streaming, JSON Lines & SSE

0.135.0

0.137.0, 0.137.2

2026-06-14, 2026-06-18

router.routes read as a flat list; breaking: APIRouter and APIRoute instances are now preserved

iter_route_contexts() for the effective routes

Project Structure & Layered Architecture

0.137.0 and 0.137.2

0.138.0, 0.139.0, 0.141.0

2026-06-20, 2026-07-01, 2026-07-29

StaticFiles mounted by hand, plus a catch-all route for client-side routing

app.frontend() and router.frontend() serve a built SPA, with dependencies (0.139.0) and check_dir="auto" (0.141.0)

Static Files, Templates & Frontend

0.138.0, 0.139.0 and 0.141.0

0.142.0

2026-09-29

opentelemetry-instrumentation-fastapi and FastAPIInstrumentor set up by hand

Native OpenTelemetry support: traces, metrics and logs configured with OTEL_* environment variables or the telemetry argument

Performance & Observability

0.142.0

Current: strict Content-Type (0.132.0)
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel


class Book(BaseModel):
    title: str


strict = FastAPI()
lenient = FastAPI(strict_content_type=False)    # opt out only for legacy clients


@strict.post("/books")
def create_strict(book: Book) -> Book:
    return book


@lenient.post("/books")
def create_lenient(book: Book) -> Book:
    return book


body = '{"title": "Dune"}'
print(TestClient(strict).post("/books", content=body).status_code)
print(TestClient(strict).post("/books", content=body, headers={"Content-Type": "application/json"}).status_code)
print(TestClient(lenient).post("/books", content=body).status_code)
422
200
200
Current: JSON Lines and Server-Sent Events by yield (0.134.0 and 0.135.0)
from collections.abc import AsyncIterable

from fastapi import FastAPI
from fastapi.sse import EventSourceResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class Book(BaseModel):
    title: str


@app.get("/books/stream")
async def stream_books() -> AsyncIterable[Book]:
    yield Book(title="Dune")
    yield Book(title="Emma")


@app.get("/books/events", response_class=EventSourceResponse)
async def book_events() -> AsyncIterable[Book]:
    yield Book(title="Dune")


client = TestClient(app)
lines = client.get("/books/stream")
print(lines.headers["content-type"], lines.text.splitlines())
events = client.get("/books/events")
print(events.headers["content-type"], events.text.strip().splitlines())
application/jsonl ['{"title":"Dune"}', '{"title":"Emma"}']
text/event-stream; charset=utf-8 ['data: {"title":"Dune"}']

Server-Sent Events are described on Server-Sent Events and SPA serving on Frontend.

Current: serving a built front end with app.frontend() (0.138.0)
import tempfile
from pathlib import Path

from fastapi import FastAPI
from fastapi.testclient import TestClient

dist = Path(tempfile.mkdtemp())
(dist / "index.html").write_text("<html><body>SPA</body></html>")

app = FastAPI()


@app.get("/api/books")
def list_books() -> list[str]:
    return ["Dune"]


app.frontend("/", directory=str(dist))

client = TestClient(app)
print(client.get("/api/books").json())
print(client.get("/", headers={"Accept": "text/html"}).text)
print(client.get("/books/42", headers={"Accept": "text/html"}).text)
['Dune']
<html><body>SPA</body></html>
<html><body>SPA</body></html>

Native OpenTelemetry (0.142.0)

Release 0.142.0 (2026-09-29) is the newest feature in the tables. Older guidance wires up the opentelemetry-instrumentation-fastapi package by hand. Native support needs only an endpoint, supplied through environment variables:

export OTEL_SERVICE_NAME=bookshelf-api
export OTEL_EXPORTER_OTLP_ENDPOINT=https://collector.example.com
fastapi run

Customization is the telemetry argument of FastAPI(…​). This example sends spans to an in-memory exporter so it can run without a collector:

Current: telemetry argument with an in-memory exporter
from fastapi import FastAPI
from fastapi.testclient import TestClient
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))

app = FastAPI(telemetry={"tracer_provider": provider})


@app.get("/books/{book_id}")
def read_book(book_id: int) -> dict[str, int]:
    return {"book_id": book_id}


TestClient(app).get("/books/1")
print(sorted({span.name for span in exporter.get_finished_spans()}))
['GET /books/{book_id}', 'fastapi.dependencies', 'fastapi.endpoint', 'fastapi.serialization']

Sending the data to a real collector was not run here, because it needs one.

Corrected Forms for Frequent Mistakes

Printed books and tutorials contain code that never worked, and mistakes copy easily from one source to another. These are the correct forms of the most frequent ones, so you can recognise and fix them in older code:

  • Types come from typing or the standard library, and models from pydantic; str is the built-in string type, and BaseModel is the base class for models.

  • Table metadata comes from SQLModel or SQLAlchemy as documented on SQL Databases with SQLModel.

  • Streaming a large file means yielding it in chunks (see Streaming, JSON Lines & SSE), not reading the whole file into memory first.

  • Every import should be checked against the official reference, and every example should run before it is trusted, which is how the examples on this page were prepared.

Upgrade Checklist

  • Pin FastAPI and read the release notes between your current and target versions, concentrating on entries marked breaking (0.106.0, 0.132.0, 0.137.0 among those listed above).

  • Move to fastapi[standard] and the fastapi command; drop fastapi-slim.

  • Remove Pydantic v1 code before upgrading past 0.126.0, using the Pydantic migration guide and bump-pydantic.

  • Replace on_event with lifespan, python-jose and passlib with PyJWT and pwdlib, and utcnow() with datetime.now(timezone.utc).

  • Check tests that expected 403 for missing credentials, and clients that send JSON without a JSON Content-Type.

  • Move Query() and Depends() defaults to Annotated gradually; both styles keep working.

  • Re-run your tests with a warning filter set to error to find deprecated calls such as ORJSONResponse early.