Building APIs with Litestar
What Is Litestar?
Litestar is an ASGI web framework for Python that gives you a clean, decorator-driven way to build web applications and REST APIs. It sits somewhere between the minimalism of FastAPI and the flexibility of Starlette, with built-in OpenAPI support, request validation via Pydantic, and a plugin system for data layers.
This guide covers the core building blocks: route handlers, request and response handling, dependency injection, and data validation. By the end you’ll have a working API with create, read, update, and delete operations.
Project Setup
Install Litestar and Uvicorn:
pip install litestar uvicorn
Pydantic comes bundled with Litestar as a dependency, so there’s nothing extra to install.
Your First Route Handler
Litestar uses HTTP method decorators to define routes. Import them directly from litestar:
from litestar import Litestar, get, post, put, patch, delete
@get("/")
def index() -> dict:
return {"message": "Hello from Litestar"}
app = Litestar(route_handlers=[index])
Run the server with Uvicorn:
uvicorn main:app --reload
The --reload flag restarts the server when you change the code, which is useful during development.
Route Handlers
Path Parameters
Capture dynamic segments from the URL by adding typed parameters to your handler function:
@get("/items/{item_id:int}")
def get_item(item_id: int) -> dict:
return {"id": item_id, "name": f"Item {item_id}"}
The type annotation int tells Litestar to parse the path segment as an integer. If the segment doesn’t parse as an integer, clients get a 400 response automatically. Supported type converters are str, int, float, uuid, and datetime (ISO 8601 format).
Multiple Paths
Attach a single handler to multiple paths by passing a list:
@get(["/status", "/health"])
def health_check() -> dict:
return {"status": "ok"}
Sync vs Async Handlers
By default, synchronous handlers run in a thread pool. You can disable this with sync_to_thread=False:
@get("/sync-heavy", sync_to_thread=False)
def sync_handler() -> dict:
# Runs in the same thread — fine for CPU-bound work
result = sum(range(1_000_000))
return {"result": result}
For async handlers, just use async def. The framework handles them correctly without extra configuration:
@get("/async-example")
async def async_handler() -> dict:
# Litestar awaits this correctly
return {"message": "async works"}
Request and Response
Returning Data
Route handlers can return several types. The most common is a Pydantic model or dataclass, which Litestar serializes to JSON automatically:
from pydantic import BaseModel
class Item(BaseModel):
id: int
name: str
price: float
@get("/items/{item_id:int}")
def get_item(item_id: int) -> Item:
return Item(id=item_id, name=f"Item {item_id}", price=9.99)
Returning None produces a 204 No Content response.
Controlling Status Codes and Headers
Use Response when you need to set specific headers or status codes:
from litestar import get, Response
from pydantic import BaseModel
class Resource(BaseModel):
id: int
name: str
@get("/resources")
def retrieve_resource() -> Response[Resource]:
return Response(
Resource(id=1, name="example resource"),
headers={"X-Custom-Header": "value"},
status_code=201
)
Setting Cookies
Use Cookie from litestar.datastructures to set HTTP cookies:
from litestar import get, Response
from litestar.datastructures import Cookie
@get("/set-cookie")
def set_cookie_handler() -> Response[dict]:
cookie = Cookie(
key="session_id",
value="abc123xyz",
http_only=True,
max_age=3600
)
return Response({"ok": True}, cookies=[cookie])
Data Validation with Pydantic
Request Bodies
Define a Pydantic model and type-annotate the handler parameter:
from pydantic import BaseModel
from litestar import post
class CreateItem(BaseModel):
name: str
description: str | None = None
price: float
@post("/items")
def create_item(data: CreateItem) -> dict:
return {"created": data.model_dump()}
If a client sends invalid JSON or missing required fields, Litestar returns a 422 Unprocessable Entity response automatically.
Query Parameters
Use Annotated to add query parameters with validation:
from typing import Annotated
from litestar import get, Query
@get("/search")
def search(
q: Annotated[str, Query(description="Search term")],
limit: Annotated[int, Query(ge=1, le=100)] = 10
) -> dict:
return {"query": q, "limit": limit}
The ge=1, le=100 constraints enforce minimum and maximum values. Clients that violate these constraints get a clear validation error.
Combining Path, Query, and Body
Mix path parameters, query parameters, and request bodies in the same handler:
from pydantic import BaseModel
from typing import Annotated
from litestar import put, Query
class UpdateItem(BaseModel):
name: str
price: float
@put("/items/{item_id:int}")
def update_item(
item_id: int,
data: UpdateItem,
partial: Annotated[bool, Query(description="Apply partial update")] = False
) -> dict:
return {
"id": item_id,
"name": data.name,
"price": data.price,
"partial": partial
}
Dependency Injection
Registering Dependencies
Dependencies are registered via the dependencies parameter on route handlers, using the Provide class:
from typing import Generator
from litestar import Litestar, get, Provide
DB_CONNECTIONS = {"open": False}
def db_connection() -> Generator[dict[str, bool], None, None]:
"""Opens a DB connection before the handler runs, closes it after."""
DB_CONNECTIONS["open"] = True
yield DB_CONNECTIONS
DB_CONNECTIONS["open"] = False
@get("/data", dependencies={"db": Provide(db_connection)})
def my_handler(db: dict) -> dict:
return {"connection_open": db["open"]}
The generator pattern ensures the connection closes after the handler finishes, even if an exception occurs.
Dependency Scopes
Dependencies have three scopes:
singleton— one instance for the entire application lifetimerequest— one instance per HTTP requestdependency— one instance per dependency injection chain (default)
from litestar import get, Provide
from litestar.di import Dependency
@get("/cached", dependencies={"cache": Provide(some_dependency, scope="singleton")})
def handler(cache) -> dict:
return {"cached": cache}
Practical Example: Authentication Dependency
A common pattern is an authentication dependency that validates a token and returns the current user:
from typing import Annotated
from litestar import get, Provide, Request
from litestar.exceptions import NotAuthorizedException
def get_current_user(request: Request) -> dict:
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise NotAuthorizedException("Missing or invalid authorization header")
# In production, verify the JWT here
token = auth_header.split(" ", 1)[1]
return {"user_id": token.split(":")[0], "token": token}
@get("/profile", dependencies={"user": Provide(get_current_user)})
def profile(user: dict) -> dict:
return {"user_id": user["user_id"]}
Application Structure
Organizing Handlers
Group related handlers into classes using Controller:
from litestar import Controller, get, post
class ItemController(Controller):
path = "/items"
@get()
def list_items(self) -> dict:
return {"items": []}
@post()
def create_item(self) -> dict:
return {"created": True}
@get("/{item_id:int}")
def get_item(self, item_id: int) -> dict:
return {"id": item_id}
Register the controller with Litestar:
from litestar import Litestar
app = Litestar(route_handlers=[ItemController])
This gives you routes at /items, /items (POST), and /items/{item_id} automatically.
Mounting Sub-Applications
Mount a separate ASGI application at a sub-path:
from litestar import Litestar
from some_other_app import another_app
app = Litestar(route_handlers=[])
app.mount("/api/v1", another_app)
The mounted app handles all paths under /api/v1.
Building a CRUD API
Putting it all together, here is a complete in-memory CRUD API:
from typing import Annotated
from pydantic import BaseModel, Field
from litestar import (
Litestar,
get,
post,
put,
patch,
delete,
Controller,
Query,
raise_http_exception,
)
ITEMS: dict[int, dict] = {1: {"id": 1, "name": "Laptop", "price": 999.99}}
class CreateItem(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0)
class UpdateItem(BaseModel):
name: str | None = None
price: float | None = None
class ItemController(Controller):
path = "/items"
@get()
def list_items(
self,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
offset: Annotated[int, Query(ge=0)] = 0
) -> dict:
items = list(ITEMS.values())
return {"items": items[offset : offset + limit], "total": len(items)}
@get("/{item_id:int}")
def get_item(self, item_id: int) -> dict:
if item_id not in ITEMS:
raise_http_exception(status_code=404, detail="Item not found")
return ITEMS[item_id]
@post()
def create_item(self, data: CreateItem) -> dict:
new_id = max(ITEMS.keys(), default=0) + 1
item = {"id": new_id, "name": data.name, "price": data.price}
ITEMS[new_id] = item
return {"created": item}
@put("/{item_id:int}")
def replace_item(self, item_id: int, data: CreateItem) -> dict:
if item_id not in ITEMS:
raise_http_exception(status_code=404, detail="Item not found")
ITEMS[item_id] = {"id": item_id, "name": data.name, "price": data.price}
return {"updated": ITEMS[item_id]}
@patch("/{item_id:int}")
def update_item(self, item_id: int, data: UpdateItem) -> dict:
if item_id not in ITEMS:
raise_http_exception(status_code=404, detail="Item not found")
current = ITEMS[item_id]
if data.name is not None:
current["name"] = data.name
if data.price is not None:
current["price"] = data.price
return {"updated": current}
@delete("/{item_id:int}")
def delete_item(self, item_id: int) -> dict:
if item_id not in ITEMS:
raise_http_exception(status_code=404, detail="Item not found")
deleted = ITEMS.pop(item_id)
return {"deleted": deleted}
app = Litestar(route_handlers=[ItemController])
Run it with uvicorn main:app --reload and test it:
# List items
curl http://localhost:8000/items
# Create an item
curl -X POST http://localhost:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "Keyboard", "price": 79.99}'
# Get a specific item
curl http://localhost:8000/items/1
# Update an item (partial)
curl -X PATCH http://localhost:8000/items/1 \
-H "Content-Type: application/json" \
-d '{"price": 899.99}'
# Delete an item
curl -X DELETE http://localhost:8000/items/1
Common Gotchas
Type annotations are required. Every handler parameter and return value needs a type annotation for OpenAPI schema generation and request validation to work. Missing annotations are silently ignored, which means validation and documentation will be incomplete.
Pydantic v2 methods. Litestar 2.x uses Pydantic v2. Use model_dump() and model_validate(), not the v1 methods .dict() and .parse_obj().
The starlite package is dead. If you find older tutorials referencing from starlite import ..., that code is stale. The project was renamed to Litestar. Always use from litestar import ....
Async handlers work correctly by default. You do not need to set sync_to_thread=False for async handlers — Litestar handles them correctly out of the box. Only use sync_to_thread=False when you specifically want a sync handler to run in the same thread.
Next Steps
With these building blocks in place, here are next steps to expand your API:
- OpenAPI docs — Litestar generates OpenAPI documentation automatically. Visit
/schema/openapi.jsonor/schema/swagger(requirespip install openapi-spec-validator). - Testing — Use
pytestwithpytest-litestarfor a test client that calls handlers without starting a server. - Database integration — Explore the
litestar.contrib.sqlalchemyplugin for SQLAlchemy integration, or use an async ORM like SQLModel or asyncpg directly. - Middleware — Apply middleware at the application or route level for cross-cutting concerns like logging, CORS, and authentication.
The official Litestar documentation has deep guides on all of these topics.
See Also
- Pydantic Guide — A practical guide to Pydantic v2, covering models,
Field, validators, and configuration. - Async/Await Patterns — Intermediate patterns for async Python, including并发 (concurrency), cancellation, and error handling.
- Python Type Hints — A practical guide to Python’s type system, from basic annotations to
TypedDictand generics.