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.

Litestar routing with type annotations
Type annotations in. OpenAPI docs out. No extra decorators needed.

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:

FeatureFlask/DjangoFastAPILitestar
Async supportLimitedFirst-classFirst-class
Type-driven validationManualPydanticPydantic + attrs + msgspec
OpenAPI generationPluginBuilt-inBuilt-in, more control
Dependency injectionManual/pluginBasicLayered, scoped
DTOsManualManualBuilt-in
WebSocketsPluginBasicFirst-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.

Litestar path parameter coercion
{product_id:int} — path coercion before your handler even runs.

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

Litestar CRUD response filtering
CRUD is easy. The interesting part is what you exclude from the response.
# 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.