Intermediate
FastAPI popularized the pattern of using Python type hints to define API contracts, but if you have spent time with it in production you have probably hit its limitations: limited built-in dependency injection, no first-class support for data transfer objects (DTOs), and a plugin ecosystem that requires assembling multiple third-party packages. Litestar (formerly Starlite) takes the same type-hint-first philosophy and extends it significantly — built-in DTOs, a layered dependency injection system, first-class WebSocket and Server-Sent Events support, and automatic OpenAPI schema generation that actually stays in sync with your code.
Litestar is a fully async Python web framework built on Starlette and Uvicorn. It is production-ready, actively maintained, and designed specifically for building APIs rather than full-stack web applications. You need Python 3.9+, and pip install litestar[standard] gets you the full stack including Uvicorn and the development server.
This article walks through everything you need to build async APIs with Litestar: routing and handlers, path and query parameters, request body validation, dependency injection, middleware, DTOs for input/output control, WebSocket handlers, and a real-life task management API. By the end you will have a production-ready Litestar API that you can extend for your own projects.
Litestar Async API: Quick Example
Install Litestar and run a minimal API to see the structure:
# quick_litestar.py
from litestar import Litestar, get, post
from pydantic import BaseModel
class Item(BaseModel):
name: str
price: float
# In-memory store (replace with a database in production)
items: list[Item] = []
@get("/items")
async def list_items() -> list[Item]:
return items
@post("/items")
async def create_item(data: Item) -> Item:
items.append(data)
return data
app = Litestar(route_handlers=[list_items, create_item])
# Run with: uvicorn quick_litestar:app --reload
# Or: litestar run --reload
Test it:
# POST an item
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Python Book", "price": 49.99}'
# Output:
{"name":"Python Book","price":49.99}
# GET all items
curl http://127.0.0.1:8000/items
# Output:
[{"name":"Python Book","price":49.99}]
Litestar automatically generates an OpenAPI spec at /schema and a Swagger UI at /schema/swagger. Unlike frameworks where you annotate your functions with OpenAPI decorators after the fact, Litestar derives the schema directly from your Python type annotations — no duplication, no drift.
What Is Litestar and Why Use It?
Litestar is positioned between FastAPI (simple, widely adopted) and frameworks like Django REST Framework (comprehensive, heavyweight). It shares FastAPI’s type-hint-driven approach but adds features that FastAPI requires plugins for:
| Feature | Flask/Django | FastAPI | Litestar |
|---|---|---|---|
| Async support | Limited | First-class | First-class |
| Type-driven validation | Manual | Pydantic | Pydantic + attrs + msgspec |
| OpenAPI generation | Plugin | Built-in | Built-in, more control |
| Dependency injection | Manual/plugin | Basic | Layered, scoped |
| DTOs | Manual | Manual | Built-in |
| WebSockets | Plugin | Basic | First-class |
The library works with Pydantic, attrs, and msgspec as data validation backends, so you are not locked into one serialization library. For high-performance APIs where serialization speed matters, msgspec is significantly faster than Pydantic.
Installation and Project Setup
# Terminal
pip install "litestar[standard]"
# For msgspec support (faster serialization):
pip install "litestar[standard,msgspec]"
# project structure
# myapi/
# app.py -- main application
# routes/ -- route handlers
# models.py -- data models
# deps.py -- dependencies
# verify_install.py
import litestar
print(f"Litestar version: {litestar.__version__}")
# Quick health check to confirm the app starts
from litestar import Litestar, get
@get("/health")
async def health() -> dict:
return {"status": "ok"}
app = Litestar(route_handlers=[health])
print("App created successfully")
Output:
Litestar version: 2.x.x
App created successfully
Routing and Route Handlers
Litestar uses decorator-based routing. Each HTTP method has its own decorator: @get, @post, @put, @patch, @delete. Handlers are plain async functions — no class inheritance required (though controllers are available for grouping):
# routing_example.py
from litestar import Litestar, get, post, put, delete
from litestar.exceptions import NotFoundException
from pydantic import BaseModel
from typing import Optional
class Product(BaseModel):
id: int
name: str
price: float
in_stock: bool = True
# Simulated database
_db: dict[int, Product] = {
1: Product(id=1, name="Widget", price=9.99),
2: Product(id=2, name="Gadget", price=24.99),
}
@get("/products")
async def list_products(in_stock: Optional[bool] = None) -> list[Product]:
"""List products, optionally filtered by stock status."""
products = list(_db.values())
if in_stock is not None:
products = [p for p in products if p.in_stock == in_stock]
return products
@get("/products/{product_id:int}")
async def get_product(product_id: int) -> Product:
"""Get a single product by ID."""
if product_id not in _db:
raise NotFoundException(f"Product {product_id} not found")
return _db[product_id]
@post("/products")
async def create_product(data: Product) -> Product:
"""Create a new product."""
_db[data.id] = data
return data
@delete("/products/{product_id:int}", status_code=204)
async def delete_product(product_id: int) -> None:
"""Delete a product. Returns 204 No Content."""
if product_id not in _db:
raise NotFoundException(f"Product {product_id} not found")
del _db[product_id]
app = Litestar(route_handlers=[list_products, get_product, create_product, delete_product])
Path parameters use the {name:type} syntax where the type is int, str, float, uuid, or path. Litestar validates and converts the path segment automatically — if someone passes a non-integer to /products/{product_id:int}, they get a 400 error before your handler is ever called. Query parameters are declared as typed function parameters with optional defaults.
Dependency Injection
Litestar has a layered dependency injection system where dependencies can be scoped to the entire app, a router, a controller, or a single handler. This is more flexible than FastAPI’s flat dependency approach:
# dependency_injection.py
from litestar import Litestar, get
from litestar.di import Provide
import asyncio
# A simulated async database connection
class Database:
def __init__(self, url: str):
self.url = url
self.connected = False
async def connect(self) -> None:
await asyncio.sleep(0.001) # simulate connection
self.connected = True
async def query(self, sql: str) -> list[dict]:
# Simulate a query result
return [{"id": 1, "result": f"Data from: {sql[:30]}"}]
async def get_db() -> Database:
"""Dependency: creates and returns a connected database instance."""
db = Database(url="postgresql://localhost/myapp")
await db.connect()
return db
@get("/users", dependencies={"db": Provide(get_db)})
async def list_users(db: Database) -> list[dict]:
"""Handler receives db via dependency injection."""
users = await db.query("SELECT * FROM users LIMIT 10")
return users
# App-level dependency (available to all handlers)
app = Litestar(
route_handlers=[list_users],
dependencies={"db": Provide(get_db)} # set at app level for global access
)
print("Dependency injection app created")
Output:
Dependency injection app created
Litestar supports dependency scoping: a database connection defined at the router level is shared across all handlers in that router but not across the entire app. This prevents accidentally sharing state between requests. When a dependency is an async generator function, Litestar treats it as a context manager — the setup code runs before the handler and the teardown code runs after, making it clean to handle connections and transactions.
Data Transfer Objects (DTOs)
DTOs control which fields are exposed in the API, separate from your internal data models. This solves the common problem of accidentally exposing password hashes or internal IDs in your API responses:
# dto_example.py
from litestar import Litestar, get, post
from litestar.dto import DTOConfig
from litestar.contrib.pydantic import PydanticDTO
from pydantic import BaseModel
from typing import Optional
class User(BaseModel):
"""Internal model -- has fields we don't want to expose."""
id: int
username: str
email: str
password_hash: str # never expose this
internal_notes: str = "" # internal admin field
# ReadDTO: expose only safe fields in responses
class UserReadDTO(PydanticDTO[User]):
config = DTOConfig(exclude={"password_hash", "internal_notes"})
# WriteDTO: accept only these fields on creation
class UserCreateDTO(PydanticDTO[User]):
config = DTOConfig(exclude={"id", "password_hash", "internal_notes"})
_users: dict[int, User] = {}
_next_id = 1
@post("/users", dto=UserCreateDTO, return_dto=UserReadDTO)
async def create_user(data: User) -> User:
global _next_id
# data arrives with id=None and no password_hash (excluded by DTO)
user = User(
id=_next_id,
username=data.username,
email=data.email,
password_hash="$2b$12$hashed_value_here", # would bcrypt in real app
internal_notes="",
)
_users[_next_id] = user
_next_id += 1
return user # UserReadDTO strips password_hash before serializing
@get("/users/{user_id:int}", return_dto=UserReadDTO)
async def get_user(user_id: int) -> User:
return _users[user_id]
app = Litestar(route_handlers=[create_user, get_user])
print("DTO app created -- password_hash excluded from all responses")
Output:
DTO app created -- password_hash excluded from all responses
The dto= parameter controls the input shape (what clients send), and return_dto= controls the output shape (what clients receive). Litestar enforces both automatically — your handler works with the full internal model, while the API contract only exposes what you allow. This separation prevents the most common API security mistake: including sensitive fields in responses because the internal model has them.
Real-Life Example: Task Management API
# task_api.py
"""
Complete task management API using Litestar.
Demonstrates routing, validation, DTOs, and error handling.
"""
from __future__ import annotations
from litestar import Litestar, get, post, patch, delete
from litestar.exceptions import NotFoundException
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
from enum import Enum
class Priority(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class Status(str, Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
DONE = "done"
class Task(BaseModel):
id: int
title: str = Field(min_length=1, max_length=200)
description: str = ""
priority: Priority = Priority.MEDIUM
status: Status = Status.PENDING
created_at: datetime = Field(default_factory=datetime.utcnow)
completed_at: Optional[datetime] = None
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = ""
priority: Priority = Priority.MEDIUM
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
status: Optional[Status] = None
priority: Optional[Priority] = None
# In-memory store
_tasks: dict[int, Task] = {}
_next_id = 1
@get("/tasks")
async def list_tasks(
status: Optional[Status] = None,
priority: Optional[Priority] = None,
) -> list[Task]:
tasks = list(_tasks.values())
if status:
tasks = [t for t in tasks if t.status == status]
if priority:
tasks = [t for t in tasks if t.priority == priority]
return sorted(tasks, key=lambda t: t.created_at, reverse=True)
@post("/tasks", status_code=201)
async def create_task(data: TaskCreate) -> Task:
global _next_id
task = Task(id=_next_id, **data.model_dump())
_tasks[_next_id] = task
_next_id += 1
return task
@get("/tasks/{task_id:int}")
async def get_task(task_id: int) -> Task:
if task_id not in _tasks:
raise NotFoundException(f"Task {task_id} not found")
return _tasks[task_id]
@patch("/tasks/{task_id:int}")
async def update_task(task_id: int, data: TaskUpdate) -> Task:
if task_id not in _tasks:
raise NotFoundException(f"Task {task_id} not found")
task = _tasks[task_id]
updates = data.model_dump(exclude_none=True)
if "status" in updates and updates["status"] == Status.DONE:
updates["completed_at"] = datetime.utcnow()
updated = task.model_copy(update=updates)
_tasks[task_id] = updated
return updated
@delete("/tasks/{task_id:int}", status_code=204)
async def delete_task(task_id: int) -> None:
if task_id not in _tasks:
raise NotFoundException(f"Task {task_id} not found")
del _tasks[task_id]
app = Litestar(route_handlers=[list_tasks, create_task, get_task, update_task, delete_task])
Test the API:
# Create a task
curl -X POST http://localhost:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write unit tests", "priority": "high"}'
# Output:
{
"id": 1,
"title": "Write unit tests",
"description": "",
"priority": "high",
"status": "pending",
"created_at": "2026-05-19T08:00:00",
"completed_at": null
}
# Mark as done
curl -X PATCH http://localhost:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "done"}'
# Filter by status
curl "http://localhost:8000/tasks?status=done"
This API is ready to extend: swap the in-memory dict for SQLAlchemy or SQLite with aiosqlite, add JWT authentication as an app-level middleware, or add pagination parameters to list_tasks. The Litestar structure keeps each concern separate — routing logic in the handlers, data shape in the models, and access control in middleware or dependencies.
Frequently Asked Questions
How does Litestar compare to FastAPI in production?
Litestar benchmarks show similar or slightly better performance than FastAPI on most workloads, since both are built on Starlette and ASGI. The main practical differences are: Litestar has built-in DTOs (no need to create separate response models), a more structured dependency injection system with scoping, and first-class support for msgspec serialization which is significantly faster than Pydantic for high-throughput APIs. FastAPI has a larger ecosystem and more tutorials; Litestar is the better choice if you value stronger typing and want to avoid extra plugins.
Can I use SQLAlchemy with Litestar?
Yes — Litestar has first-class SQLAlchemy integration via litestar.contrib.sqlalchemy. It includes base classes for async SQLAlchemy models and a repository pattern that handles sessions and transactions cleanly. Install with pip install "litestar[sqlalchemy]". The dependency injection system makes it straightforward to inject a database session per-request with proper cleanup after each response.
How do I test a Litestar application?
Litestar provides a TestClient that wraps httpx.AsyncClient. Use it with pytest-asyncio for async test support: from litestar.testing import TestClient. The client sends requests directly to your ASGI app without a network connection, making tests fast and deterministic. Dependency overrides work the same as in FastAPI — replace the real database dependency with an in-memory version in tests.
Does Litestar support WebSockets?
Yes, WebSocket handlers are first-class in Litestar. Decorate an async function with @websocket("/ws/chat") and use WebSocket as the parameter type. Litestar handles connection upgrades and provides ws.receive_data() and ws.send_data() methods. For Server-Sent Events (one-way streaming), use the ServerSentEvent response type.
Can I customize the generated OpenAPI schema?
Litestar gives you fine-grained control over the OpenAPI output through the OpenAPIConfig class passed to the app. You can set the title, version, contact info, license, and custom schema paths. Individual handlers support tags, summary, description, and deprecated parameters. For fields, Pydantic’s Field(description=..., example=...) annotations flow directly into the schema.
Conclusion
Litestar provides a production-ready async API framework with capabilities that FastAPI requires plugins to match: built-in DTOs, layered dependency injection, and support for multiple serialization backends. We covered route handlers and HTTP verbs, path and query parameters, dependency injection with scoping, DTOs for input/output control, and built the complete task management API.
The logical next step is to connect the task API to a real database using litestar.contrib.sqlalchemy and add JWT authentication middleware. Litestar’s layered architecture makes both additions clean — the routing code stays untouched while you swap the data layer underneath it.
Full documentation is at docs.litestar.dev with comprehensive guides on SQLAlchemy integration, authentication, testing, and deployment.