Skip to content

Migrating from FastAPI

Most FastAPI concepts have a direct jero equivalent — the shape changes, the idea carries over. This page is the map. For why the shapes differ, read Comparison; for a routes-and-dependencies side-by-side, its FastAPI section.

The map

FastAPI jero
@app.get("/x") on a function An Endpoint class with a get method
A group of CRUD routes / APIRouter A Resource class — method names are the operations
Pydantic BaseModel msgspec Struct (below)
Field(...) constraints Annotated[..., msgspec.Meta(...)] — validated and documented
Depends(...) Constructor arguments, wired in wire
Query() / Path() / Header() / Cookie() params params / path / headers / cookies Structs
response.set_cookie(...) / response.delete_cookie(...) SetCookie(...) / SetCookie.expire(...) on any response
UploadFile, File(), Form() A form Struct with FilePart / FormPart
HTTPException(404, detail=...) A typed HTTPError subclass you raise
response_model= The return annotation — it is the schema
status_code=201 Automatic for create; otherwise a response wrapper
lifespan / startup events wire + _enter / _aenter
BackgroundTasks per request One typed, app-wide BackgroundTasks queue
ASGI middleware The compiled middleware protocol
CORSMiddleware self._include_cors(CORS(...))
Auth dependency (Depends(get_user)) An authenticator at the mount; handlers declare user
fastapi.testclient.TestClient jero.testing.TestClient — also sync, in-process
/docs (automatic) self._include_openapi(...) — explicit, one line

Models: BaseModel → Struct

# FastAPI / Pydantic
class Widget(BaseModel):
    name: str = Field(min_length=1)
    price_cents: int

    class Config:
        alias_generator = to_camel
# jero / msgspec
from typing import Annotated
from msgspec import Meta, Struct


class Widget(Struct, rename="camel"):
    name: Annotated[str, Meta(min_length=1)]
    price_cents: int

Same contract: validated on decode, camelCase on the wire, constraints in the OpenAPI schema. Give your project one base Struct fixing the wire convention and inherit it everywhere.

Errors: HTTPException → HTTPError

# FastAPI
raise HTTPException(status_code=404, detail="widget not found")
# jero
class WidgetNotFoundError(
    HTTPError,
    type="widget-not-found",
    title="Widget not found",
    status=404,
): ...


raise WidgetNotFoundError()

Four lines once, instead of a string at every raise site — clients dispatch on the stable type, and the error documents itself in the spec. Errors covers parameterized details and custom body formats.

What doesn't carry over

  • A DI container. There is no Depends. Build objects in wire, pass them to constructors — Wiring & lifecycle is the whole story.
  • request.state. Middleware can't pass state to handlers. Bind the header you need in the handler, or carry it on the auth user.
  • A thread pool for def handlers. FastAPI runs a def handler in a thread pool, so a blocking call there leaves the event loop free. jero runs it on the event loop, so the same blocking call makes every other request wait. Port blocking handlers to async def and await asyncio.to_thread(...) the blocking call, or switch to an async client; see Can handlers be sync?.
  • Returning dicts. A JSON body is a Struct — a dict return is a startup error, and that's what buys validation, schema, and speed.
  • Body-rewriting middleware. Compression, caching, and ETags belong in your server or reverse proxy — see Deployment.
  • Static files. Small ones, yes — _include_assets serves them from memory; large files and catch-all SPA fallbacks still belong on your proxy or CDN.

Porting order that works

  1. Port models to Structs (mechanical, type-checker-guided).
  2. Group routes into Resource / Endpoint classes; move Depends chains into constructors and wire.
  3. Replace HTTPExceptions with typed errors.
  4. Wire _include_openapi, boot the app, and fix each WiringError it reports — startup validation is the migration checklist running itself.
  5. Point your test suite at jero.testing.TestClient.