Python

Building REST APIs with Litestar (formerly Starlite)

If you’ve outgrown Flask and find FastAPI’s approach too function-centric for larger projects, Litestar deserves a serious look. Building REST APIs with Litestar gives you a type-driven, class-based framework that handles validation, serialization, dependency injection, and OpenAPI docs out of the box. This guide walks through setting up a Litestar project, defining route handlers, validating requests, integrating a database, and structuring your API for production use. By the end, you’ll have a working API with patterns that scale beyond a simple CRUD example.

What Is Litestar?

Litestar is a high-performance Python ASGI framework for building web APIs, formerly known as Starlite. It was renamed to Litestar in 2023 and has since evolved into a fully independent framework with its own ecosystem. Unlike Flask or Django REST Framework, Litestar is built from the ground up around Python type hints. As a result, route handlers automatically validate incoming data, serialize responses, and generate OpenAPI documentation based on your type annotations alone. Additionally, Litestar provides class-based controllers, a built-in dependency injection system, and native support for msgspec, Pydantic, dataclasses, and attrs for data modeling.

Setting Up a Litestar Project

First, create a new project directory and install Litestar. The framework supports Python 3.8 and above, but Python 3.10+ is recommended for the best type hint experience.

# Create project directory and virtual environment
mkdir litestar-api && cd litestar-api
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install Litestar with standard extras
pip install litestar[standard]

# Expected output:
# Successfully installed litestar-2.x.x anyio click ...

The [standard] extra includes uvicorn for running the development server, Jinja2 for templates, and other commonly needed packages. Now create the entry point for your application.

# app/main.py
from litestar import Litestar, get


@get("/")
async def health_check() -> dict[str, str]:
    return {"status": "healthy"}


app = Litestar(route_handlers=[health_check])

Run the development server with:

# Start the development server with auto-reload
litestar run --reload

# Expected output:
# Using Litestar app from app.main:app
# Starting server on http://127.0.0.1:8000

Navigate to http://127.0.0.1:8000/schema to see the auto-generated OpenAPI documentation. Litestar generates this entirely from your type annotations, which means your docs stay in sync with your code without any extra effort. If you’re coming from FastAPI, this will feel familiar — for a comparison of the two frameworks and others, check out our Flask vs FastAPI vs Django comparison.

Defining Route Handlers

Litestar supports both function-based and class-based route handlers. Function-based handlers work well for simple endpoints, while class-based controllers shine as your API grows.

Function-Based Handlers

# app/routes/items.py
from litestar import get, post, put, delete
from app.models import Item, ItemCreate, ItemUpdate


@get("/items")
async def list_items() -> list[Item]:
    """Return all items. Litestar serializes the list automatically."""
    return await Item.fetch_all()


@get("/items/{item_id:int}")
async def get_item(item_id: int) -> Item:
    """Path parameters are extracted and typed from the route pattern."""
    return await Item.fetch_by_id(item_id)


@post("/items")
async def create_item(data: ItemCreate) -> Item:
    """The 'data' parameter is automatically parsed from the request body."""
    return await Item.create(data)

Notice that the data parameter in the create_item handler is automatically deserialized and validated from the request body. Litestar inspects the type annotation and handles parsing, validation, and error responses without any decorators or explicit parsing code. This type-driven approach is central to why Python type hints matter for modern Python development.

Class-Based Controllers

For APIs with related endpoints, class-based controllers group routes under a shared path and let you share dependencies across handlers.

# app/controllers/user_controller.py
from litestar import Controller, get, post, put, delete
from litestar.di import Provide
from app.models import User, UserCreate, UserUpdate
from app.services import UserService


class UserController(Controller):
    path = "/users"

    @get()
    async def list_users(self, user_service: UserService) -> list[User]:
        """List all users."""
        return await user_service.get_all()

    @get("/{user_id:int}")
    async def get_user(
        self, user_id: int, user_service: UserService
    ) -> User:
        """Get a single user by ID."""
        return await user_service.get_by_id(user_id)

    @post()
    async def create_user(
        self, data: UserCreate, user_service: UserService
    ) -> User:
        """Create a new user from the validated request body."""
        return await user_service.create(data)

    @put("/{user_id:int}")
    async def update_user(
        self, user_id: int, data: UserUpdate, user_service: UserService
    ) -> User:
        """Update an existing user."""
        return await user_service.update(user_id, data)

    @delete("/{user_id:int}")
    async def delete_user(
        self, user_id: int, user_service: UserService
    ) -> None:
        """Delete a user. Returns 204 No Content automatically."""
        await user_service.delete(user_id)

The controller groups all user-related routes under /users. Each method receives a UserService instance through dependency injection, which keeps the handler logic thin and testable. This pattern is similar to NestJS controllers if you’re coming from the Node.js world.

Request Validation with Data Models

Litestar validates request data automatically based on the type annotations in your handler signatures. You can use dataclasses, Pydantic models, msgspec structs, or attrs classes for your data models. Msgspec is the default and recommended option because it’s significantly faster than Pydantic for serialization and deserialization.

# app/models.py
from dataclasses import dataclass
from datetime import datetime


@dataclass
class UserCreate:
    """Request model for creating a user. All fields are required."""
    email: str
    name: str
    password: str


@dataclass
class UserUpdate:
    """Request model for updating a user. All fields are optional."""
    email: str | None = None
    name: str | None = None


@dataclass
class User:
    """Response model returned from the API."""
    id: int
    email: str
    name: str
    created_at: datetime

When a request hits the create_user endpoint, Litestar automatically parses the JSON body into a UserCreate instance. If the body is missing required fields or contains invalid types, Litestar returns a 400 error with a descriptive message. Consequently, you don’t need to write manual validation logic in your handlers.

For more complex validation rules, Pydantic models give you field validators and model validators. If you’ve worked with Pydantic validation in FastAPI, the same patterns apply in Litestar.

# app/models.py (Pydantic alternative)
from pydantic import BaseModel, EmailStr, field_validator


class UserCreate(BaseModel):
    email: EmailStr
    name: str
    password: str

    @field_validator("password")
    @classmethod
    def validate_password_strength(cls, v: str) -> str:
        if len(v) < 8:
            raise ValueError("Password must be at least 8 characters")
        return v

Litestar detects whether your model is a dataclass, Pydantic model, or msgspec struct and uses the appropriate serialization backend automatically.

Dependency Injection

Litestar’s dependency injection system is one of its strongest differentiators. Dependencies are declared in handler signatures and resolved automatically at request time. This keeps your handlers focused on business logic rather than setup and teardown.

# app/dependencies.py
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker


async def provide_db_session(
    session_maker: async_sessionmaker[AsyncSession],
) -> AsyncGenerator[AsyncSession, None]:
    """Provide a database session that auto-closes after the request."""
    async with session_maker() as session:
        yield session


async def provide_user_service(
    db_session: AsyncSession,
) -> "UserService":
    """Provide a UserService with a database session injected."""
    from app.services import UserService
    return UserService(db_session)

Register these dependencies at the application level or the controller level:

# app/main.py
from litestar import Litestar
from litestar.di import Provide
from app.controllers.user_controller import UserController
from app.dependencies import provide_db_session, provide_user_service


app = Litestar(
    route_handlers=[UserController],
    dependencies={
        "db_session": Provide(provide_db_session),
        "user_service": Provide(provide_user_service),
    },
)

Dependencies can depend on other dependencies, forming a resolution chain. In this example, provide_user_service receives db_session automatically because it’s already registered as a dependency. Furthermore, generator dependencies (using yield) handle cleanup automatically — the session closes after the response is sent, even if an error occurs during the request.

Database Integration with SQLAlchemy

Litestar includes a first-party SQLAlchemy plugin that simplifies async database operations. Here’s how to set up a full database integration with an async PostgreSQL connection.

# app/db.py
from sqlalchemy import Column, Integer, String, DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from datetime import datetime


class Base(DeclarativeBase):
    pass


class UserModel(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
    name: Mapped[str] = mapped_column(String(100))
    password_hash: Mapped[str] = mapped_column(String(255))
    created_at: Mapped[datetime] = mapped_column(
        DateTime, server_default=func.now()
    )
# app/services.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import UserModel
from app.models import User, UserCreate


class UserService:
    def __init__(self, db_session: AsyncSession) -> None:
        self.session = db_session

    async def get_all(self) -> list[User]:
        result = await self.session.execute(select(UserModel))
        rows = result.scalars().all()
        return [
            User(
                id=row.id,
                email=row.email,
                name=row.name,
                created_at=row.created_at,
            )
            for row in rows
        ]

    async def create(self, data: UserCreate) -> User:
        user = UserModel(
            email=data.email,
            name=data.name,
            password_hash=hash_password(data.password),
        )
        self.session.add(user)
        await self.session.commit()
        await self.session.refresh(user)
        return User(
            id=user.id,
            email=user.email,
            name=user.name,
            created_at=user.created_at,
        )

Configure the SQLAlchemy plugin in your application setup:

# app/main.py
from litestar import Litestar
from litestar.contrib.sqlalchemy.plugins import (
    SQLAlchemyAsyncConfig,
    SQLAlchemyPlugin,
)

sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="postgresql+asyncpg://user:pass@localhost:5432/mydb",
)

app = Litestar(
    route_handlers=[UserController],
    plugins=[SQLAlchemyPlugin(config=sqlalchemy_config)],
)

The plugin manages the engine, session factory, and connection lifecycle for you. In production, you’d store the connection string in environment variables and use connection pooling settings appropriate for your load. For deployment patterns, see our guide on deploying Python apps with Docker and Kubernetes.

Error Handling and Middleware

Litestar provides structured error handling through exception handlers that keep your route logic clean.

# app/exceptions.py
from litestar import Response, Request
from litestar.status_codes import HTTP_404_NOT_FOUND, HTTP_409_CONFLICT


class NotFoundError(Exception):
    """Raised when a requested resource does not exist."""
    def __init__(self, resource: str, id: int) -> None:
        self.resource = resource
        self.id = id


class DuplicateError(Exception):
    """Raised when a unique constraint would be violated."""
    def __init__(self, field: str) -> None:
        self.field = field


def not_found_handler(request: Request, exc: NotFoundError) -> Response:
    return Response(
        content={"detail": f"{exc.resource} with id {exc.id} not found"},
        status_code=HTTP_404_NOT_FOUND,
    )


def duplicate_handler(request: Request, exc: DuplicateError) -> Response:
    return Response(
        content={"detail": f"A record with this {exc.field} already exists"},
        status_code=HTTP_409_CONFLICT,
    )

Register exception handlers at the application level:

# In app/main.py
app = Litestar(
    route_handlers=[UserController],
    exception_handlers={
        NotFoundError: not_found_handler,
        DuplicateError: duplicate_handler,
    },
)

For cross-cutting concerns like logging, CORS, or authentication, Litestar supports middleware. However, for common patterns like CORS, Litestar provides built-in configuration:

from litestar.config.cors import CORSConfig

cors_config = CORSConfig(
    allow_origins=["https://yourdomain.com"],
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_credentials=True,
)

app = Litestar(
    route_handlers=[UserController],
    cors_config=cors_config,
)

Real-World Scenario: Migrating a FastAPI Service to Litestar

Consider a small backend team maintaining a FastAPI service with 25-30 endpoints that powers a SaaS dashboard. The API started as a collection of function-based route handlers, but as the codebase grew, related endpoints became scattered across multiple files with duplicated dependency setup. The team found themselves passing the same database session and service objects through every handler manually.

After evaluating their options, the team chose to migrate to Litestar incrementally. They started with one controller — the user management endpoints — and converted the five related route handlers into a single UserController class. The dependency injection system meant the database session and service layer were declared once at the controller level rather than repeated in every handler signature.

The migration itself took about a week for the initial controller, mostly because the team needed to adjust their test fixtures. The immediate benefits were clearer code organization and reduced boilerplate. However, the key trade-off was ecosystem maturity — some FastAPI middleware packages didn’t have direct Litestar equivalents, so the team wrote thin adapters for two ASGI middleware libraries. Over the following weeks, they migrated the remaining endpoints controller by controller.

The important takeaway is that Litestar and FastAPI share the same ASGI foundation, which makes incremental migration practical. Teams don’t need to rewrite everything at once, and both frameworks can even run side by side behind a reverse proxy during the transition period.

How Does Litestar Compare to FastAPI?

Both Litestar and FastAPI are modern Python ASGI frameworks that leverage type hints. However, they differ in several practical ways that matter for larger projects.

FeatureLitestarFastAPI
Route handlersFunctions and class-based controllersFunctions (classes via APIRouter)
Dependency injectionBuilt-in, supports layered scopingBuilt-in, function-level
Serializationmsgspec (default), Pydantic, dataclassesPydantic only
OpenAPI generationAutomatic from typesAutomatic from types
PerformanceSlightly faster (msgspec default)Fast (Pydantic v2)
Ecosystem sizeGrowing, smaller communityLarge, mature ecosystem
ORM integrationFirst-party SQLAlchemy pluginThird-party or manual

For small projects or quick prototypes, FastAPI’s larger ecosystem and community support make it the pragmatic choice. For larger APIs where code organization and testability matter, Litestar’s class-based controllers and layered dependency injection provide better structure. To understand how FastAPI works in practice, see our FastAPI REST API guide.

When to Use Litestar

  • Your API has many related endpoints that benefit from class-based controller grouping
  • You need a structured dependency injection system that supports layered scoping across controllers
  • Performance is a priority and you want msgspec’s faster serialization by default
  • Your team prefers an opinionated framework that enforces consistent patterns across the codebase
  • You’re building a new Python API and want automatic OpenAPI docs, validation, and serialization from type hints alone

When NOT to Use Litestar

  • You need a large ecosystem of third-party plugins and middleware — FastAPI or Django REST Framework have more options available today
  • Your team is already productive with FastAPI and the project doesn’t have pain points that Litestar specifically addresses
  • You’re building a simple prototype or microservice with fewer than 10 endpoints — the organizational benefits of controllers don’t justify the learning curve for a small service
  • You need extensive community resources like tutorials, Stack Overflow answers, and blog posts — FastAPI has significantly more community content available

Common Mistakes with Litestar

  • Using Pydantic when msgspec would suffice. Litestar’s default serialization with msgspec is faster and uses less memory. Unless you need Pydantic’s advanced validators, stick with dataclasses or msgspec structs for better performance.
  • Putting all handlers in a single file. Litestar’s controller pattern exists for a reason. Group related endpoints into controllers and organize them by domain. A flat file with 30 function-based handlers defeats the purpose of using Litestar over lighter frameworks.
  • Ignoring the dependency injection hierarchy. Dependencies can be scoped at the application, controller, or handler level. Declaring every dependency at the application level when some are only needed by one controller adds unnecessary overhead to every request.
  • Not using DTOs to control response shapes. Litestar’s DTO system lets you exclude sensitive fields like password hashes from responses without creating separate response models for every endpoint. Skipping DTOs often leads to accidentally exposing internal fields.
  • Forgetting that generator dependencies handle cleanup. When using yield in a dependency provider, the cleanup code after yield runs automatically. Wrapping the handler in a try/finally block to close sessions or connections duplicates work the framework already handles.

Conclusion

Building REST APIs with Litestar gives you a type-safe, well-structured framework that scales from small services to large API projects. Start with function-based handlers for simple endpoints, then move to class-based controllers as your API grows beyond a handful of routes. The built-in dependency injection, automatic validation, and OpenAPI generation eliminate the boilerplate that accumulates in less opinionated frameworks.

Litestar works best for teams that want enforced structure and are willing to trade FastAPI’s larger ecosystem for better code organization patterns. For your next step, explore our guide on deploying Python apps with Docker and Kubernetes to take your Litestar API to production.

Leave a Comment