pyguides

Building APIs with Litestar: Route Handlers, Validation, and CRUD

What Is Litestar?

Litestar is an ASGI web framework for building APIs in Python. It gives you a clean, decorator-driven way to build web applications and REST APIs, sitting between the minimalism of FastAPI and the flexibility of Starlette. Built-in features include 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. Both libraries are now ready to use, and you can verify the installation by importing them in a Python shell.

Now that the environment is set up, the first thing to understand about Litestar is how it maps incoming HTTP requests to your Python functions. Litestar uses a decorator-based approach where each HTTP method gets its own decorator, making it immediately clear which function handles which verb and path combination.

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])

The code above defines a minimal Litestar application with a single route. The @get("/") decorator tells Litestar to call index() for HTTP GET requests to the root path, and the function’s return type annotation of dict ensures the response gets serialized as JSON automatically. To see this in action, you need a server to run the application. Litestar is built on the ASGI standard, so any ASGI server works, but Uvicorn is the most common choice for development.

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. With the server running, you can now expand your application beyond a single hardcoded response. The real power of a web framework comes from handling dynamic input, starting with capturing values directly from the URL itself.

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"}

The path parameters and multi-path features give you control over URL routing, but how your handler code actually executes matters just as much for performance and correctness. Litestar distinguishes between synchronous and asynchronous handlers and handles each appropriately without forcing you to pick one style for your entire application.

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}

This synchronous approach works well for CPU-intensive work but blocks the event loop. For I/O-bound operations like database queries or external API calls, an async handler is a better choice. Litestar detects the difference between sync and async handler functions automatically and manages each one with the appropriate execution model, so you can mix both styles in the same application.

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"}

Now that you can define routes that receive input through path parameters and run either synchronously or asynchronously, the next concern is what those handlers send back. Litestar accepts several return types and handles serialization, status codes, and headers without boilerplate code.

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. While returning a Pydantic model handles the common case of a 200 response with JSON body, real APIs often need to set custom HTTP status codes, add response headers, or both. Litestar provides a dedicated Response wrapper for these situations.

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
    )

The Response wrapper gives you full control over HTTP-level concerns like status codes and custom headers. Another common response-level need is setting cookies, which Litestar handles through a dedicated Cookie data structure that enforces security best practices like HttpOnly and SameSite by default.

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])

Setting cookies is one part of the request-response cycle, but validation of incoming data is just as important. Litestar integrates tightly with Pydantic to validate request bodies, query parameters, and path parameters before they ever reach your handler logic. This means you can define validation rules in your Pydantic models and trust that invalid data will be rejected before your business logic runs.

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. This body validation handles POST and PUT payloads, but many endpoints also need to accept filter parameters, pagination settings, and search terms through the URL query string. Litestar provides a dedicated Query construct with built-in validation constraints for these cases.

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. Having covered request bodies with Pydantic models and query parameters with the Query construct, the next step is combining all three input sources (path parameters, query parameters, and request bodies) in a single handler to handle real-world update operations.

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
    }

Now that you can validate and combine all three input sources, the next question is how to share common resources like database connections, configuration objects, and authentication checks across multiple handlers. Litestar addresses this with a dependency injection system that wires dependencies into your handler function parameters automatically.

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. This works well for simple cases, but real applications often need more control over how dependencies are instantiated and shared. Litestar provides three lifespan scopes that determine whether a dependency is created once per application, once per request, or once per injection chain.

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}

Choosing the right scope depends on the resource type: database connections typically use request scope so each request gets its own connection, while expensive-to-create objects like ML model instances or configuration loaders benefit from singleton scope. A particularly common pattern across web frameworks is extracting the current user from an authentication token, which fits naturally into Litestar’s dependency injection model.

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"]}

With authentication handled through dependency injection, you now have a clean separation between the concern of identifying the current user and the business logic of your route handlers. As your application grows beyond a few handlers, organizing them into logical groups becomes essential for readability and maintenance. Litestar provides a Controller class that bundles related routes together under a shared path prefix.

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}

The Controller class above defines three endpoints under the /items path, with each method decorated by the HTTP verb it handles. Controllers keep related handler functions together in one place rather than scattered across the module. After defining a controller, you register it with the Litestar application instance just like you would a standalone route handler function.

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. Controllers handle the common case of grouping related endpoints, but some applications need to delegate entire URL prefixes to separate ASGI applications. Litestar supports this through sub-application mounting, which is useful for versioned APIs, micro-frontends, or integrating third-party ASGI services.

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. With controllers for organization and sub-applications for composition, you have the structural tools to build larger applications. Now let’s bring together everything covered so far: route handlers with path and query parameters, Pydantic request validation, dependency injection for shared resources, and controller-based organization. The result is a complete in-memory CRUD API that demonstrates how these pieces fit together in a real application.

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])

The CRUD API above demonstrates the full Litestar workflow: a Controller class bundles handlers under a shared path, Pydantic models enforce input validation on create and update requests, query parameters with constraints handle pagination, and error responses are raised with precise HTTP status codes. Once you save this code to a file, you can start the server and interact with the API immediately.

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.
  • FastAPI Quickstart — Build REST APIs with FastAPI, covering path operations, request bodies, and dependency injection.
  • 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.