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”:

Concept NestJS (TS) FastAPI (Python) Litestar (Python)
Philosophy Opinionated, DI-first, decorators Minimal, function-first, type hints Layered, controller-first, type hints
Route grouping @Controller('tasks') class APIRouter(prefix="/tasks") class TaskController(Controller)
Handlers class methods free functions class methods
DI constructor injection, providers Depends() per parameter Provide() + dependencies={} per layer
Validation class-validator DTOs Pydantic models Pydantic / msgspec / attrs / dataclass
Auth @UseGuards(AuthGuard) Depends(get_current_user) guards=[requires_auth]
Lifespan onModuleInit / lifecycle hooks lifespan async context manager on_startup / lifespan
OpenAPI @nestjs/swagger built in (/docs) built in (/schema)
ASGI server uvicorn / hypercorn uvicorn / 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:

Concern FastAPI Litestar
Core install uv add fastapi uv add litestar
ASGI server uv add uvicorn (separate) uv add uvicorn (or use litestar run)
Data models Pydantic (bundled) msgspec (bundled); Pydantic/attrs optional
CLI none (use uvicorn/fastapi CLI) litestar CLI bundled
OpenAPI UI Swagger + ReDoc at /docs, /redoc Swagger/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: ...
FastAPI Litestar
Grouping APIRouter(prefix="/tasks") class TaskController(Controller): path = "/tasks"
Handler form free function class method (self is fine)
Path param type from the function signature inline {id:int} and signature
Shared deps for the group APIRouter(dependencies=[...]) (side-effect only) dependencies={...} injected into every method
Shared guards/middleware APIRouter(dependencies=[...]) workaround guards=[...], 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
Layer Declared on Scope
App Litestar(dependencies={}) every route in the app
Router Router(dependencies={}) every route under that router’s path
Controller class 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).

FastAPI Litestar
Hook style on_startup=[...], on_shutdown=[...]
Context-manager style lifespan=lifespan (single CM) lifespan=[lifespan] (list of CMs)
Shared state app.state / request.app.state app.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.

FastAPI Litestar
Body model types Pydantic only msgspec, Pydantic, attrs, dataclass, TypedDict
Body parameter inferred (the model-typed param) the param named data
Fastest path Pydantic v2 (Rust core) msgspec Struct (used internally)
Field-level control Pydantic Field per-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},
)
Concern FastAPI Litestar
Raise an error raise HTTPException(404, detail=...) raise NotFoundException(detail=...)
Error types one HTTPException + status_code typed hierarchy (NotFoundException, …)
Custom mapping @app.exception_handler(Exc) exception_handlers={Exc: handler} (any layer)
Validation errors RequestValidationError (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
FastAPI Litestar
Cross-cutting wrap ASGI/@app.middleware("http") middleware=[...] (any layer)
Authorization Depends(get_current_user) per route guards=[...] (any layer), typed
Runs as a dependency, inline with the handler dedicated 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.

FastAPI Litestar
Dev server uvicorn app:app --reload / fastapi dev litestar run --reload / uvicorn app:app --reload
Inspect routes /docs litestar routes (CLI) + /schema
Production uvicorn/gunicorn workers uvicorn/granian/hypercorn workers

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

Dimension FastAPI Litestar
First release 2018 2021 (as Starlite); renamed 2023
ASGI Yes (Starlette underneath) Yes (own ASGI toolkit)
Route grouping APIRouter + free functions Controller classes + routers
DI Depends(), per parameter Provide() + dependencies={}, layered
DI layers router-level side effects only app / router / controller / handler
Data models Pydantic v2 msgspec, Pydantic, attrs, dataclass, TypedDict
Serialization speed fast (Pydantic Rust core) faster on plain encode (msgspec)
Authorization dependency-based typed guards
Plugins middleware + libraries first-class plugin system (SQLAlchemy, etc.)
OpenAPI /docs, /redoc /schema (Swagger/ReDoc/Scalar/RapiDoc)
Ecosystem / answers very large growing, smaller
Hiring familiarity most candidates know it fewer do
Docs excellent, beginner-friendly excellent, more reference-style
If you… Reach for
Want the safe default a new hire already knows FastAPI
Are shipping a small/medium service fast FastAPI
Rely heavily on Pydantic v2 validators/computed fields FastAPI (or Pydantic models in Litestar)
Need the biggest ecosystem / most tutorials FastAPI
Are building a large app with many route groups Litestar
Are tired of repeating Depends() everywhere Litestar
Want controller classes / a Nest-like structure Litestar
Use dataclasses/attrs/msgspec as your domain models Litestar
Want layered guards for authz Litestar
Want the tightest possible serialization throughput Litestar (msgspec)
Concept What you learned
Why a second framework FastAPI’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
DI Provide() + dependencies={} injected by name, across app/router/controller/handler layers
Lifespan on_startup/on_shutdown hooks or a lifespan context manager (same shape as FastAPI)
DTOs msgspec/Pydantic/attrs/dataclass; the data param is the body; dedicated DTO layer
Errors typed exceptions you raise + exception_handlers={} at any layer
Middleware/guards ASGI middleware plus typed guards=[] for authorization
OpenAPI built in at /schema (multiple renderers)
Choosing FastAPI 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.