Skip to content

Litestar — the Same API, Compared

Module 07 built the Task API in FastAPI — the default modern Python web framework, and the one you’ll meet in most job postings. This module rebuilds the same API in Litestar so you get a direct side-by-side. Litestar (formerly Starlite) is the credible 2026 alternative: ASGI, the same Pydantic models you already wrote, but a different opinion about how an app should grow — controller classes, layered dependency injection, and typed guards instead of per-route Depends. The point of this module isn’t “switch to Litestar.” It’s to see the same problem solved two ways so you can pick deliberately.

FastAPI is excellent and it’s the right default. But it has a growth pattern that strains as an app gets large, and it’s worth naming honestly:

  • DI is per-parameter and per-route. Every handler that needs the DB session repeats session: AsyncSession = Depends(get_session). There’s no concept of “all routes in this router share these dependencies” beyond APIRouter(dependencies=[...]) (which run for side effects but don’t inject named values cleanly).
  • Handlers are free functions. Grouping is by APIRouter + file convention. There’s no first-class “controller” that owns a base path, shared dependencies, and a set of related handlers as one unit.
  • It’s effectively Pydantic-only for request/response models. That’s fine — but if your domain is dataclasses, attrs, or you want raw msgspec speed, you’re swimming upstream.

Litestar takes different positions on each:

  • Controller classes group related handlers under one base path with shared config — much like a NestJS or Spring @RestController.
  • Layered DI: dependencies declared at the app, router, controller, or handler layer, with inner layers overriding outer ones. You declare dependencies={"repo": Provide(...)} once on a controller and every handler in it receives repo.
  • Multiple data libraries: Pydantic, msgspec, attrs, dataclasses, and TypedDict all work as DTOs. msgspec is Litestar’s native fast path — it’s one of the fastest serialization libraries in Python, and Litestar uses it internally.
  • Typed guards for authorization, plugins for cross-cutting integrations (SQLAlchemy, etc.), and OpenAPI built in (served at /schema).

If you read Module 07, this row reads as “the same idea, different surface”:

ConceptNestJS (TS)FastAPI (Python)Litestar (Python)
PhilosophyOpinionated, DI-first, decoratorsMinimal, function-first, type hintsLayered, controller-first, type hints
Route grouping@Controller('tasks') classAPIRouter(prefix="/tasks")class TaskController(Controller)
Handlersclass methodsfree functionsclass methods
DIconstructor injection, providersDepends() per parameterProvide() + dependencies={} per layer
Validationclass-validator DTOsPydantic modelsPydantic / msgspec / attrs / dataclass
Auth@UseGuards(AuthGuard)Depends(get_current_user)guards=[requires_auth]
LifespanonModuleInit / lifecycle hookslifespan async context manageron_startup / lifespan
OpenAPI@nestjs/swaggerbuilt in (/docs)built in (/schema)
ASGI serveruvicorn / hypercornuvicorn / litestar run / granian

Same uv workflow as everywhere else in this guide — no pip, no virtualenv. Litestar publishes the litestar CLI as part of the package, so you also get litestar run/litestar version for free.

  1. Create the project and add dependencies:

    Terminal window
    uv init example-app
    cd example-app
    uv add litestar uvicorn
  2. Optionally add the dev tools the rest of the guide uses:

    Terminal window
    uv add --dev ruff ty

Litestar bundles msgspec, the OpenAPI generator, and the CLI — there’s no separate “starter” to install. Compare the two dependency lists:

ConcernFastAPILitestar
Core installuv add fastapiuv add litestar
ASGI serveruv add uvicorn (separate)uv add uvicorn (or use litestar run)
Data modelsPydantic (bundled)msgspec (bundled); Pydantic/attrs optional
CLInone (use uvicorn/fastapi CLI)litestar CLI bundled
OpenAPI UISwagger + ReDoc at /docs, /redocSwagger/ReDoc/Scalar/RapiDoc at /schema

Litestar’s route decorators (@get, @post, @put, @delete, @patch) look like FastAPI’s, with one key difference: the HTTP method is the decorator name, not a method on an app/router object. The bigger structural difference is the Controller class — handlers live as methods on a class that owns a base path.

from litestar import get
@get("/hello")
async def hello() -> dict[str, str]:
return {"message": "Hello, World!"}

The same endpoint in FastAPI is @app.get("/hello") on the app object. Litestar’s decorator is standalone — you register the handler with the app (or a controller, or a router) afterwards, which is what lets the same handler be wired at different layers.

// NestJS — controller method
@Controller()
export class HelloController {
@Get("hello")
hello() {
return { message: "Hello, World!" };
}
}

This is the headline feature. A Controller owns a path, and its methods are handlers whose paths are appended to it:

from litestar import Controller, get, post, delete
class TaskController(Controller):
path = "/tasks"
@get()
async def list_tasks(self) -> list[Task]: ...
@get("/{task_id:str}")
async def get_task(self, task_id: str) -> Task: ...
@post()
async def create_task(self, data: CreateTask) -> Task: ...
@delete("/{task_id:str}")
async def delete_task(self, task_id: str) -> None: ...

Path parameters carry an inline type: {task_id:str}, {task_id:int}, {task_id:uuid}. Litestar parses and validates them by that type before your handler runs — {id:int} rejects /tasks/abc with a 400 automatically, where FastAPI relies on the parameter’s type hint to do the same.

The FastAPI equivalent is an APIRouter plus free functions:

# FastAPI — router + free functions
router = APIRouter(prefix="/tasks")
@router.get("")
async def list_tasks() -> list[Task]: ...
@router.get("/{task_id}")
async def get_task(task_id: str) -> Task: ...
FastAPILitestar
GroupingAPIRouter(prefix="/tasks")class TaskController(Controller): path = "/tasks"
Handler formfree functionclass method (self is fine)
Path param typefrom the function signatureinline {id:int} and signature
Shared deps for the groupAPIRouter(dependencies=[...]) (side-effect only)dependencies={...} injected into every method
Shared guards/middlewareAPIRouter(dependencies=[...]) workaroundguards=[...], middleware=[...] on the class

The app’s route_handlers=[] list is where controllers and standalone handlers get wired in — the Litestar equivalent of FastAPI’s app.include_router(...):

from litestar import Litestar
app = Litestar(route_handlers=[TaskController, hello])
# FastAPI equivalent
app = FastAPI()
app.include_router(task_router)
app.add_api_route("/hello", hello)

Routers nest too: Router(path="/api/v1", route_handlers=[TaskController]) gives every task route a /api/v1/tasks/... prefix — that’s your fourth DI/config layer (app → router → controller → handler).

This is where Litestar diverges most from FastAPI, and it’s the reason to learn it.

In FastAPI a dependency is a callable, and you request it by adding a parameter with Depends(...). Every handler that needs it repeats the parameter:

# FastAPI
async def get_repo() -> TaskRepository:
return TaskRepository(...)
@router.get("/{task_id}")
async def get_task(task_id: str, repo: TaskRepository = Depends(get_repo)) -> Task:
...
@router.delete("/{task_id}")
async def delete_task(task_id: str, repo: TaskRepository = Depends(get_repo)) -> None:
... # repeated on every handler that needs repo

Litestar: Provide, declared once per layer

Section titled “Litestar: Provide, declared once per layer”

In Litestar you register a provider in a dependencies={} dict at whatever layer makes sense, and every handler under that layer receives it by matching the key name to a parameter name:

from litestar import Controller, get, delete
from litestar.di import Provide
async def provide_repo() -> TaskRepository:
return TaskRepository(...)
class TaskController(Controller):
path = "/tasks"
dependencies = {"repo": Provide(provide_repo)} # declared ONCE
@get("/{task_id:str}")
async def get_task(self, task_id: str, repo: TaskRepository) -> Task:
... # `repo` is injected by name — no Depends() needed
@delete("/{task_id:str}")
async def delete_task(self, task_id: str, repo: TaskRepository) -> None:
... # same here, zero repetition

The matching is by parameter name: the key "repo" in the dict fills any handler parameter named repo. No Depends marker in the signature.

A dependency can be declared at any of four layers; inner layers override outer ones for the same key. This is the part FastAPI has no direct equivalent for:

app = Litestar(
route_handlers=[TaskController],
dependencies={"clock": Provide(provide_clock)}, # 1. app — everywhere
)
router = Router(
path="/api",
route_handlers=[TaskController],
dependencies={"tenant": Provide(provide_tenant)}, # 2. router — under /api
)
class TaskController(Controller):
dependencies = {"repo": Provide(provide_repo)} # 3. controller — these handlers
@get("/{task_id:str}", dependencies={"repo": Provide(provide_cached_repo)})
async def get_task(self, task_id: str, repo: TaskRepository) -> Task:
... # 4. handler — overrides #3 for THIS route
LayerDeclared onScope
AppLitestar(dependencies={})every route in the app
RouterRouter(dependencies={})every route under that router’s path
Controllerclass C(Controller): dependencies =every handler in the controller
Handler@get(dependencies={})that single handler

By default Litestar caches a dependency’s result within a single request — if two things need repo, provide_repo runs once. Use Provide(fn, use_cache=False) to opt out. Providers can be async def, plain def (run in a threadpool), generators (for cleanup, like FastAPI’s yield dependencies), or even a sync_to_thread=True plain callable.

async def provide_session() -> AsyncGenerator[AsyncSession, None]:
async with session_factory() as session: # setup
yield session # injected value
# teardown runs after the response, like FastAPI's yield-dependency

Litestar gives you two equivalent ways to run setup/teardown, and they map cleanly onto FastAPI’s lifespan.

# Option A: on_startup / on_shutdown hooks (simple lists of callables)
async def open_pool(app: Litestar) -> None:
app.state.pool = await create_pool()
async def close_pool(app: Litestar) -> None:
await app.state.pool.close()
app = Litestar(
route_handlers=[TaskController],
on_startup=[open_pool],
on_shutdown=[close_pool],
)
# Option B: lifespan context manager — the same shape as FastAPI
from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator
@asynccontextmanager
async def lifespan(app: Litestar) -> AsyncGenerator[None, None]:
app.state.pool = await create_pool() # startup
yield
await app.state.pool.close() # shutdown
app = Litestar(route_handlers=[TaskController], lifespan=[lifespan])

The lifespan form is identical in spirit to FastAPI’s lifespan= argument — an async context manager that yields once. If you wrote FastAPI’s lifespan in Module 07, you can paste the body here almost verbatim. Shared state lives on app.state in both frameworks (request.app.state inside handlers).

FastAPILitestar
Hook styleon_startup=[...], on_shutdown=[...]
Context-manager stylelifespan=lifespan (single CM)lifespan=[lifespan] (list of CMs)
Shared stateapp.state / request.app.stateapp.state / request.app.state

In FastAPI, request and response models are Pydantic models, full stop. Litestar accepts any of msgspec Struct, Pydantic BaseModel, attrs classes, stdlib dataclasses, or TypedDict — the request body is parsed into whatever you annotate the data parameter with.

# msgspec — Litestar's native, fastest path
import msgspec
class CreateTask(msgspec.Struct):
title: str
description: str = ""
@post()
async def create_task(self, data: CreateTask) -> Task:
... # `data` is the parsed, validated body — the param MUST be named `data`

The request body always arrives as the parameter named data (Litestar’s convention), where FastAPI infers the body parameter from “the one that’s a Pydantic model.” Validation failures produce a 400 with a structured error in both.

FastAPILitestar
Body model typesPydantic onlymsgspec, Pydantic, attrs, dataclass, TypedDict
Body parameterinferred (the model-typed param)the param named data
Fastest pathPydantic v2 (Rust core)msgspec Struct (used internally)
Field-level controlPydantic Fieldper-library, plus Litestar DTO layer

Litestar also has a dedicated DTO abstraction (SQLAlchemyDTO, MsgspecDTO, DataclassDTO) for the common “expose a subset of a DB model, rename fields, exclude secrets” problem — declared as dto= / return_dto= on a handler. FastAPI solves the same problem by hand-writing separate Create/Read/Update Pydantic models, which is exactly what we do in the sub-project to keep the diff against Module 07 tight.

FastAPI uses HTTPException plus @app.exception_handler(...). Litestar ships a hierarchy of typed HTTP exceptions (NotFoundException, ValidationException, PermissionDeniedException, …) you simply raise, and an exception_handlers={} dict for custom mapping — registerable at any layer, like dependencies.

# FastAPI
from fastapi import HTTPException
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
# Litestar — typed exceptions, no status-code literals
from litestar.exceptions import NotFoundException
if task is None:
raise NotFoundException(detail="Task not found") # -> 404 automatically

Custom handlers map an exception type (or status code) to a response, the same centralized-handler idea as FastAPI’s @app.exception_handler:

from litestar import Litestar, Request, Response
from litestar.exceptions import HTTPException
def handle_http_exc(request: Request, exc: HTTPException) -> Response:
return Response(
content={"error": exc.detail, "status": exc.status_code},
status_code=exc.status_code,
)
app = Litestar(
route_handlers=[TaskController],
exception_handlers={HTTPException: handle_http_exc},
)
ConcernFastAPILitestar
Raise an errorraise HTTPException(404, detail=...)raise NotFoundException(detail=...)
Error typesone HTTPException + status_codetyped hierarchy (NotFoundException, …)
Custom mapping@app.exception_handler(Exc)exception_handlers={Exc: handler} (any layer)
Validation errorsRequestValidationError (422)ValidationException (400)

Middleware is the same ASGI concept in both — wraps the whole request/response. The interesting Litestar-specific tool is the guard: a small typed callable that runs after routing and DI but before the handler, purpose-built for authorization. It’s Litestar’s @UseGuards from Nest, and a cleaner fit than FastAPI’s “auth is just another dependency” approach.

from litestar.connection import ASGIConnection
from litestar.handlers.base import BaseRouteHandler
from litestar.exceptions import NotAuthorizedException
def requires_auth(connection: ASGIConnection, handler: BaseRouteHandler) -> None:
if not connection.headers.get("authorization"):
raise NotAuthorizedException() # raise to deny; return None to allow
class TaskController(Controller):
path = "/tasks"
guards = [requires_auth] # applies to every handler; also valid per-handler or per-app
FastAPILitestar
Cross-cutting wrapASGI/@app.middleware("http")middleware=[...] (any layer)
AuthorizationDepends(get_current_user) per routeguards=[...] (any layer), typed
Runsas a dependency, inline with the handlerdedicated phase before the handler

Both generate OpenAPI from your type hints with zero extra annotations. The differences are cosmetic: FastAPI serves Swagger at /docs and ReDoc at /redoc; Litestar serves a schema hub at /schema with Swagger, ReDoc, Scalar, and RapiDoc renderers available out of the box, and the raw spec at /schema/openapi.json. Configure title/version via OpenAPIConfig passed to Litestar(openapi_config=...).

Two ways — the bundled CLI, or uvicorn directly (identical to how you’d run FastAPI).

Terminal window
# Litestar CLI — auto-discovers `app` in app.py / asgi.py
uv run litestar run --reload # dev, hot reload
uv run litestar run --port 8000 # pick a port
# Or uvicorn directly — same as FastAPI
uv run uvicorn app:app --reload

litestar run auto-discovers an app named app and adds niceties like --reload, --web-concurrency, and a litestar routes command that prints your whole route table. For production it’s the same advice as FastAPI: run uvicorn (or granian/ hypercorn) workers behind a process manager — see Deployment.

FastAPILitestar
Dev serveruvicorn app:app --reload / fastapi devlitestar run --reload / uvicorn app:app --reload
Inspect routes/docslitestar routes (CLI) + /schema
Productionuvicorn/gunicorn workersuvicorn/granian/hypercorn workers

You’ve now seen the same API two ways. Here’s the fair version of the decision.

DimensionFastAPILitestar
First release20182021 (as Starlite); renamed 2023
ASGIYes (Starlette underneath)Yes (own ASGI toolkit)
Route groupingAPIRouter + free functionsController classes + routers
DIDepends(), per parameterProvide() + dependencies={}, layered
DI layersrouter-level side effects onlyapp / router / controller / handler
Data modelsPydantic v2msgspec, Pydantic, attrs, dataclass, TypedDict
Serialization speedfast (Pydantic Rust core)faster on plain encode (msgspec)
Authorizationdependency-basedtyped guards
Pluginsmiddleware + librariesfirst-class plugin system (SQLAlchemy, etc.)
OpenAPI/docs, /redoc/schema (Swagger/ReDoc/Scalar/RapiDoc)
Ecosystem / answersvery largegrowing, smaller
Hiring familiaritymost candidates know itfewer do
Docsexcellent, beginner-friendlyexcellent, more reference-style
If you…Reach for
Want the safe default a new hire already knowsFastAPI
Are shipping a small/medium service fastFastAPI
Rely heavily on Pydantic v2 validators/computed fieldsFastAPI (or Pydantic models in Litestar)
Need the biggest ecosystem / most tutorialsFastAPI
Are building a large app with many route groupsLitestar
Are tired of repeating Depends() everywhereLitestar
Want controller classes / a Nest-like structureLitestar
Use dataclasses/attrs/msgspec as your domain modelsLitestar
Want layered guards for authzLitestar
Want the tightest possible serialization throughputLitestar (msgspec)
ConceptWhat you learned
Why a second frameworkFastAPI’s per-route Depends strains at scale; Litestar’s layered DI + controllers don’t
Routing@get/@post/... decorators + Controller classes with a base path and inline param types
DIProvide() + dependencies={} injected by name, across app/router/controller/handler layers
Lifespanon_startup/on_shutdown hooks or a lifespan context manager (same shape as FastAPI)
DTOsmsgspec/Pydantic/attrs/dataclass; the data param is the body; dedicated DTO layer
Errorstyped exceptions you raise + exception_handlers={} at any layer
Middleware/guardsASGI middleware plus typed guards=[] for authorization
OpenAPIbuilt in at /schema (multiple renderers)
ChoosingFastAPI is the safe default; pick Litestar deliberately for large, layered apps

Rebuild the exact Task API from Module 07 in Litestar so you can diff the two line by line — Controller class, Provide-injected repository, layered DI, a DTO, and a lifespan.