Building APIs with Litestar

· 8 min read · Updated March 21, 2026 · beginner
python litestar api pydantic web async

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 lifetime
  • request — one instance per HTTP request
  • dependency — 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.json or /schema/swagger (requires pip install openapi-spec-validator).
  • Testing — Use pytest with pytest-litestar for a test client that calls handlers without starting a server.
  • Database integration — Explore the litestar.contrib.sqlalchemy plugin 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 TypedDict and generics.