For Django and Django REST Framework users#
General concepts#
Layered configuration#
Litestar uses a layered architecture. The parts used to group routes can also be used to hierarchically organize an application. Settings and configuration like dependencies, exception handlers, guards, middleware, response cookies and headers, lifecycle hooks, OpenAPI configuration and many more can be defined on any layer, and will be merged upon registration.
The layers in hierarchical order are
1. Application (Litestar)
2. Router / Controller (these are on the same level and can be arbitrarily nested)
3. Handler (BaseRouteHandler)
When the same configuration is set on multiple layers, the value set closest to the
handler takes precedence. Django has no direct equivalent: settings are global, middleware
is global, and DRF’s permission_classes / authentication_classes / throttle_classes
are configured per view. In Litestar these are configurable at every layer.
Library-agnostic modelling#
DRF uses Serializer and ModelSerializer for request validation, response
rendering, and database-to-response mapping; Django uses Form for HTML form workflows.
Litestar is mostly agnostic about the modelling library. Internally it uses
msgspec, and ships first-class support for
Pydantic, attrs, dataclasses, and TypedDicts through its plugin system
(SerializationPlugin and
OpenAPISchemaPlugin). The body of a request is bound to a
data parameter; the return annotation drives the response shape. DTOs sit on top
when the wire shape needs to diverge from the model (see
Serializers and DTOs).
Application configuration#
Django reads DJANGO_SETTINGS_MODULE and loads a settings.py module; DRF reads
the REST_FRAMEWORK dict from the same module. Litestar is configured through
keyword arguments on the Litestar constructor. There is no
settings-module convention, but usually the factory pattern is used, allowing to
configure applications dynamically.
# settings.py
DEBUG = True
INSTALLED_APPS = ["django.contrib.contenttypes", "rest_framework", "myapp"]
MIDDLEWARE = ["django.middleware.common.CommonMiddleware"]
ROOT_URLCONF = "myapp.urls"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "db.sqlite3"}}
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework.authentication.SessionAuthentication"],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
}
from litestar import Litestar
def create_app(debug: bool = False) -> Litestar:
return Litestar(
route_handlers=[...],
middleware=[...],
plugins=[...],
openapi_config=...,
debug=debug,
)
Plugins replace INSTALLED_APPS: an ORM integration, a metrics integration, or a
custom feature ships as a plugin and is registered on the application through the
plugins keyword. See Concepts without a direct equivalent
for the broader mapping.
Route handlers#
Django wires URL patterns through urls.py and references view callables or
class-based views; DRF adds APIView, GenericAPIView, and ViewSet on top.
Litestar uses an HTTP-method decorator on a function - or a method on a
Controller - and registers handlers on a
Litestar or Router instance. The URL
pattern lives on the decorator, not in a separate file.
# views.py
from django.http import JsonResponse
def index(request):
return JsonResponse({"hello": "world"})
# urls.py
from django.urls import path
from . import views
urlpatterns = [path("", views.index)]
# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(["GET"])
def index(request):
return Response({"hello": "world"})
# urls.py same as above
from litestar import Litestar, get
@get("/")
async def index() -> dict[str, str]:
return {"hello": "world"}
app = Litestar(route_handlers=[index])
Tip
Router and Controllers can be arbitrarily nested, but disappear at runtime. When you register a controller or router on an application, they get reduced to standard, independent route handlers.
For example
class UserController(Controller):
path = "/user"
guards = [can_read_user]
@get("{user_id:str}", guards=[can_read_user])
async def get_user(self, user_id: FromPath[str]) -> User:
...
is functionally equivalent to
@get("/user/{user_id:str}", guards=[can_read_user])
async def get_user(user_id: FromPath[str]) -> User:
...
See also
Controllers#
DRF’s APIView, GenericAPIView and ViewSet group HTTP methods on a single
class. Litestar’s Controller does the same. Subclass
Controller, set path, and decorate methods with the HTTP method decorators.
The difference is that DRF views dispatch by method name, while Litestar uses the same
decorator patterns as for standalone routes:
from rest_framework.views import APIView
from rest_framework.response import Response
class ItemView(APIView):
def get(self, request, item_id):
return Response({"id": item_id})
def post(self, request):
return Response(request.data, status=201)
from litestar import Controller, Litestar, delete, get, patch, post
from litestar.params import FromPath
class ItemController(Controller):
path = "/items"
@get("/")
async def list_items(self) -> list[dict[str, int]]:
return [{"id": 1}, {"id": 2}]
@post("/")
async def create_item(self, data: dict[str, str]) -> dict[str, str]:
return data
@get("/{item_id:int}")
async def get_item(self, item_id: FromPath[int]) -> dict[str, int]:
return {"id": item_id}
@patch("/{item_id:int}")
async def update_item(self, item_id: FromPath[int], data: dict[str, str]) -> dict[str, object]:
return {"id": item_id, **data}
@delete("/{item_id:int}")
async def delete_item(self, item_id: FromPath[int]) -> None:
return None
app = Litestar(route_handlers=[ItemController])
A Controller carries the same layered configuration as a Router: dependencies,
guards, middleware, exception handlers, and OpenAPI tags can all be declared on the
class and inherited by its methods.
Tip
Controllers are just a cosmetic layers on top of a router. Under the hood, a controller instance will be converted into a router when it’s registered on the application
See also
Routers#
Django’s include("app.urls") and DRF’s SimpleRouter().register(...) mount a
sub-tree of URLs under a prefix. Litestar’s Router takes a
path and a list of route_handlers, which can themselves be handlers,
controllers, or other routers. Routers nest arbitrarily.
# myapp/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("items/", views.list_items),
path("items/<int:item_id>/", views.get_item),
]
# project/urls.py
from django.urls import include, path
urlpatterns = [path("api/v1/", include("myapp.urls"))]
from rest_framework.routers import SimpleRouter
router = SimpleRouter()
router.register(r"items", ItemViewSet, basename="item")
urlpatterns = [path("api/v1/", include(router.urls))]
from litestar import Litestar, Router, get
from litestar.params import FromPath
@get("/")
async def list_items() -> list[dict[str, int]]:
return [{"id": 1}]
@get("/{item_id:int}")
async def get_item(item_id: FromPath[int]) -> dict[str, int]:
return {"id": item_id}
items_router = Router(path="/items", route_handlers=[list_items, get_item])
api_router = Router(path="/api/v1", route_handlers=[items_router])
app = Litestar(route_handlers=[api_router])
Application state#
Django stores process-global state in module-level variables or on
django.apps.apps. Litestar exposes State,
seeded on the application and available to dependencies through the state
parameter and to handlers through request.app.state. Per-request state lives on
request.state instead and inherits from the application state
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
def ready(self):
from .clients import init_redis
init_redis()
# services.py
from litestar import Litestar, get
from litestar.datastructures import State
class RedisClient:
"""Stand-in for a configured Redis client."""
@get("/")
async def handler(state: State) -> dict[str, str]:
redis: RedisClient = state.redis
return {"client": type(redis).__name__}
app = Litestar(
route_handlers=[handler],
state=State({"redis": RedisClient()}),
)
Sync and async handlers#
Django views and DRF views are synchronous by default; Django 4+ supports
async def views through an in-process sync-to-async bridge. The bridge cuts
both ways: a sync view runs on a thread pool from the ASGI worker, an
async def view runs on the event loop, and ORM / cache / middleware calls
made from an async view are wrapped in sync_to_async to bounce back to a
thread. DRF itself is still sync-only: serializers, throttles, and the
APIView dispatch loop do not have async equivalents.
Litestar inverts the default. Handlers are async def and run directly on
the event loop. There is no implicit thread bridge: a blocking call in an
async handler blocks the worker. A def handler must declare which side it
runs on:
sync_to_thread=Trueoffloads the call to a thread pool; Use when porting blocking ORM code, file I/O, or a third-party library without async support.sync_to_thread=Falseruns the call inline on the event loop: reserved for short, non-blocking work where the offload would cost more than the call itself.
A bare def handler with neither flag emits a deprecation warning at
startup: the choice is load-bearing enough that the framework asks you to be
explicit.
def slow_view(request):
time.sleep(0.01) # implicitly runs in the request thread
return JsonResponse({"hello": "world"})
import time
from litestar import Litestar, get
@get("/", sync_to_thread=True)
def slow_handler() -> dict[str, str]:
time.sleep(0.01)
return {"hello": "world"}
@get("/fast", sync_to_thread=False)
def fast_handler() -> dict[str, str]:
return {"hello": "world"}
app = Litestar(route_handlers=[slow_handler, fast_handler])
Handlers and request data#
DRF deserialises the body once on the view level and exposes request.data,
request.query_params, request.GET, request.POST, request.FILES, and
request.COOKIES; Django’s HttpRequest exposes the lower-level versions of the
same. Litestar injects each piece through typed handler parameters using
Annotated and From* markers, so the handler signature describes the
contract.
Path and query parameters#
Django converts path parameters with the path-converter syntax (<int:item_id>)
and exposes them as positional arguments; query parameters come from
request.GET.get(...). DRF exposes them through self.kwargs and
request.query_params. Litestar uses path-converter syntax in the decorator and
binds the values through FromPath and
FromQuery.
# urls.py
path("items/<int:item_id>/", views.get_item)
# views.py
def get_item(request, item_id):
limit = int(request.GET.get("limit", "10"))
return JsonResponse({"id": item_id, "limit": limit})
class ItemView(APIView):
def get(self, request, item_id):
limit = int(request.query_params.get("limit", "10"))
return Response({"id": item_id, "limit": limit})
from litestar import Litestar, get
from litestar.params import FromPath, FromQuery
@get("/items/{item_id:int}")
async def get_item(item_id: FromPath[int], limit: FromQuery[int]) -> dict[str, int]:
return {"id": item_id, "limit": limit}
app = Litestar(route_handlers=[get_item])
To attach validation constraints or OpenAPI metadata, use the
Annotated form with PathParameter and
QueryParameter:
class LimitSerializer(serializers.Serializer):
limit = serializers.IntegerField(min_value=1, max_value=100)
class ItemView(APIView):
def get(self, request, item_id):
serializer = LimitSerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
...
from typing import Annotated
from litestar import Litestar, get
from litestar.params import PathParameter, QueryParameter
@get("/items/{item_id:int}")
async def get_item(
item_id: Annotated[int, PathParameter(gt=0)],
limit: Annotated[int, QueryParameter(gt=0, le=100)],
) -> dict[str, int]:
return {"id": item_id, "limit": limit}
app = Litestar(route_handlers=[get_item])
See also
JSON request body#
DRF’s parser populates request.data and a Serializer validates the dict into
a Python object. Litestar injects the validated body through a data parameter
typed against any supported model (dataclasses , msgspec msgspec.Struct,
Pydantic model, attrs class). Validation runs before the handler is called; an invalid
body raises ValidationException which produces a
400 - Bad Request response.
class ItemSerializer(serializers.Serializer):
name = serializers.CharField()
price = serializers.FloatField()
class ItemView(APIView):
def post(self, request):
serializer = ItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
return Response(serializer.validated_data, status=201)
from dataclasses import dataclass
from litestar import Litestar, post
from litestar.params import JSONBody
@dataclass
class Item:
name: str
price: float
@post("/items")
async def create_item(data: JSONBody[Item]) -> Item:
return data
app = Litestar(route_handlers=[create_item])
Field-level renaming, partial updates, and write-vs-read separation move to the DTO layer. See Serializers and DTOs.
Form data#
Django’s Form and DRF’s MultiPartParser / FormParser populate
request.POST. Litestar uses the same data parameter with the
URLEncodedBody marker for form-encoded bodies, or
MultipartBody for multipart.
from django.views.decorators.http import require_POST
@require_POST
def login(request):
username = request.POST["username"]
password = request.POST["password"]
return JsonResponse({"user": username})
from dataclasses import dataclass
from litestar import Litestar, post
from litestar.params import URLEncodedBody
@dataclass
class LoginForm:
username: str
password: str
@post("/login")
async def login(data: URLEncodedBody[LoginForm]) -> dict[str, str]:
return {"user": data.username}
app = Litestar(route_handlers=[login])
File uploads#
Django exposes uploaded files through request.FILES (an UploadedFile per
field). Litestar uses UploadFile, or
list[UploadFile] for multiple files under the same field name, with
a MultipartBody marker:
def upload(request):
files = request.FILES.getlist("data")
return JsonResponse({"file_names": [f.name for f in files]})
from typing import Dict, List, Optional
from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.params import MultipartBody
@post("/upload")
async def upload_files(data: MultipartBody[List[UploadFile]]) -> Dict[str, List[Optional[str]]]:
return {"file_names": [file.filename for file in data]}
app = Litestar(route_handlers=[upload_files])
from typing import Optional
from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.params import MultipartBody
@post("/upload")
async def upload_files(data: MultipartBody[list[UploadFile]]) -> dict[str, list[Optional[str]]]:
return {"file_names": [file.filename for file in data]}
app = Litestar(route_handlers=[upload_files])
from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.params import MultipartBody
@post("/upload")
async def upload_files(data: MultipartBody[list[UploadFile]]) -> dict[str, list[str | None]]:
return {"file_names": [file.filename for file in data]}
app = Litestar(route_handlers=[upload_files])
Producing responses#
Django returns HttpResponse, JsonResponse, HttpResponseRedirect and so
on, with status and headers set explicitly. DRF wraps the body in
Response(data, status=...) and renders through the configured renderer. Litestar
infers the response from the handler’s return annotation; a returned dict, dataclass,
or model serialises to JSON, str returns text/plain, and typed response
classes (Response, Stream,
Template, Redirect,
File) pick their own media type.
Default status codes#
Django defaults to 200 for every method; DRF defaults to 200 unless
status= is passed. Litestar picks the default from the HTTP method: POST
defaults to 201 Created, DELETE to 204 No Content, and everything else
to 200. Pass status_code= to the decorator to override.
Serialization#
DRF’s Response(serializer.data) round-trips through the configured renderer
(JSON by default), with a separate HTML “browsable API” renderer for browsers.
Litestar drives serialisation off the handler return annotation. Structured types
serialise to JSON; str returns text/plain; a typed response class picks its
own media type. media_type= on the decorator overrides the default.
Templates#
Django’s render(request, "template.html", context) and DRF’s
TemplateHTMLRenderer are the common cases. Litestar configures the engine once
on the application through TemplateConfig and
each handler returns a Template. The bundled engines are
Jinja, Mako, and MiniJinja; the engine slot accepts any implementation of the
template-engine protocol.
# settings.py
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
}]
# views.py
from django.shortcuts import render
def hello(request, name):
return render(request, "hello.html", {"name": name})
from pathlib import Path
from litestar import Litestar, get
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.params import FromPath
from litestar.response import Template
from litestar.template.config import TemplateConfig
@get("/hello/{name:str}")
async def hello(name: FromPath[str]) -> Template:
return Template(
template_str="<p>Hello, {{ name }}!</p>",
context={"name": name},
)
app = Litestar(
route_handlers=[hello],
template_config=TemplateConfig(
directory=Path(__file__).parent,
engine=JinjaTemplateEngine,
),
)
See also
Streaming responses#
Django uses StreamingHttpResponse(iterator); Litestar uses
Stream. Both wrap a sync or async iterator.
from django.http import StreamingHttpResponse
def stream_numbers(request):
def numbers():
for i in range(5):
yield f"{i}\n"
return StreamingHttpResponse(numbers())
from collections.abc import AsyncIterator
from litestar import Litestar, get
from litestar.response import Stream
async def numbers() -> AsyncIterator[bytes]:
for i in range(5):
yield f"{i}\n".encode()
@get("/numbers")
async def stream_numbers() -> Stream:
return Stream(numbers())
app = Litestar(route_handlers=[stream_numbers])
URL lookup#
Django’s reverse("view-name", kwargs=...) and the {% url %} template tag
build URLs from named patterns; DRF adds reverse("api-detail", request=request)
to include the absolute URL. Litestar exposes
route_reverse() on the application and
url_for() on the request. Handlers name
themselves through name= on the decorator.
# urls.py
path("", views.index, name="index")
# views.py
from django.shortcuts import redirect
from django.urls import reverse
def go_home(request):
return redirect(reverse("index"))
from litestar import Litestar, Request, get
from litestar.params import FromPath
from litestar.response import Redirect
@get("/", name="index")
async def index() -> dict[str, str]:
return {"page": "index"}
@get("/{item_id:int}", name="item-detail")
async def item_detail(item_id: FromPath[int]) -> dict[str, int]:
return {"id": item_id}
@get("/redirect")
async def to_index(request: Request) -> Redirect:
return Redirect(path=request.url_for("index"))
app = Litestar(route_handlers=[index, item_detail, to_index])
Serializers and DTOs#
DRF’s Serializer and ModelSerializer carry three concerns: validating
inbound data, rendering outbound data, and mapping between the wire shape and the
ORM model. Litestar splits these. Model libraries (dataclasses, msgspec, Pydantic,
attrs) handle validation and basic shape, applied through the data parameter
and the return annotation. DTOs decouple the wire shape from the internal model
when the two should differ: an inbound payload that excludes server-managed fields,
a response that renames fields, a partial update that accepts a subset of the
model. Litestar ships DataclassDTO,
MsgspecDTO, PydanticDTO, and
SQLAlchemyDTO (for ORM models).
The basic case: excluding a server-set field from inbound payloads:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "name", "email"]
read_only_fields = ["id"]
from dataclasses import dataclass
from uuid import UUID, uuid4
from litestar import Litestar, post
from litestar.dto import DataclassDTO, DTOConfig, DTOData
@dataclass
class User:
id: UUID
name: str
email: str
class UserWriteDTO(DataclassDTO[User]):
"""Inbound payloads cannot set the server-managed ``id`` field."""
config = DTOConfig(exclude={"id"})
@post("/users", dto=UserWriteDTO, return_dto=None)
async def create_user(data: DTOData[User]) -> User:
return data.create_instance(id=uuid4())
app = Litestar(route_handlers=[create_user])
The DTOData wrapper holds the
parsed-and-validated input; create_instance(**overrides) materialises it into the
model with server-managed fields filled in.
For ORM-backed models, SQLAlchemyDTO understands the mapped class and lets the
same configuration drive both the inbound and outbound shape. The DTO is attached
to the handler through dto= (for the request body) and return_dto= (for the
response):
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ["id", "name", "price"]
read_only_fields = ["id"]
class ItemView(generics.CreateAPIView):
serializer_class = ItemSerializer
from typing import Annotated
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from litestar import Litestar, post
from litestar.dto import DTOConfig
from litestar.plugins.sqlalchemy import SQLAlchemyDTO
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
price: Mapped[float]
WriteItemDTO = SQLAlchemyDTO[Annotated[Item, DTOConfig(exclude={"id"})]]
ReadItemDTO = SQLAlchemyDTO[Item]
@post("/items", dto=WriteItemDTO, return_dto=ReadItemDTO)
async def create_item(data: Item) -> Item:
data.id = 1
return data
app = Litestar(route_handlers=[create_item])
DRF’s partial=True on the serializer is mirrored by
DTOConfig(partial=True) on a write DTO: optional fields become permitted,
required fields become optional.
The same shape used for input is rarely the right shape for output; the DTO lets one model definition produce both, with the differences declared as configuration rather than written twice.
info
DTOs also serve as the adapter for types Litestar would not otherwise know how
to serialise, and do not offer serialization methods by themselves;
A SQLAlchemy DeclarativeBase subclass is not a dataclass and has no msgspec or
Pydantic schema, but SQLAlchemyDTO reads the mapped columns and produces a wire
schema from them, by representing the model as an abstract shape Litestar can
understand. The same pattern extends to other libraries: any DTO subclass that
understands a model type can adopt it. This is how Litestar stays
modelling-library-agnostic while still letting you return an ORM instance directly
from a handler.
See also
Dependency injection#
DRF threads request-scoped state through self.request, self.kwargs, and
self.get_serializer_context(); everything else (database clients, service
classes, configuration) is typically reached through module-level imports.
Litestar has a first-class dependency-injection container. Dependencies are
declared as a mapping from parameter name to provider, attached to the
dependencies keyword on any layer. Async callables work directly as providers;
synchronous callables are wrapped in Provide.
Inner layers override outer ones, so a router-scoped dependency replaces an application-scoped one of the same name, and a handler-scoped one replaces both.
# services.py
payments = PaymentService()
# views.py
from .services import payments
class ChargeView(APIView):
def get(self, request):
user_id = request.user.id
return Response({"user_id": user_id, "result": payments.charge(100)})
from typing import Dict
from litestar import Litestar, Router, get
from litestar.di import NamedDependency
class PaymentService:
def charge(self, amount: int) -> str:
return f"charged {amount}"
async def provide_payments() -> PaymentService:
return PaymentService()
async def provide_current_user_id() -> int:
return 42
@get("/charge")
async def charge_handler(payments: NamedDependency[PaymentService], user_id: int) -> Dict[str, object]:
return {"user_id": user_id, "result": payments.charge(100)}
router = Router(
path="/api",
route_handlers=[charge_handler],
dependencies={"user_id": provide_current_user_id},
)
app = Litestar(
route_handlers=[router],
dependencies={"payments": provide_payments},
)
from litestar import Litestar, Router, get
from litestar.di import NamedDependency
class PaymentService:
def charge(self, amount: int) -> str:
return f"charged {amount}"
async def provide_payments() -> PaymentService:
return PaymentService()
async def provide_current_user_id() -> int:
return 42
@get("/charge")
async def charge_handler(payments: NamedDependency[PaymentService], user_id: int) -> dict[str, object]:
return {"user_id": user_id, "result": payments.charge(100)}
router = Router(
path="/api",
route_handlers=[charge_handler],
dependencies={"user_id": provide_current_user_id},
)
app = Litestar(
route_handlers=[router],
dependencies={"payments": provide_payments},
)
Tip
Dependencies are part of Litestar’s layered architecture, so these can be declared on applications, routers, controllers or route handlers.
See also
Exceptions and error responses#
Django raises Http404, PermissionDenied, and SuspiciousOperation; DRF
adds APIException, NotFound, ValidationError, PermissionDenied, and
the EXCEPTION_HANDLER setting that maps them to JSON responses. Litestar exports
HTTPException and concrete subclasses
(NotFoundException,
PermissionDeniedException,
ValidationException,
NotAuthorizedException). The status code is a keyword
argument (status_code=), and a positional argument to HTTPException is
appended to detail.
from rest_framework.exceptions import NotFound
class ItemView(APIView):
def get(self, request, item_id):
if item_id != 1:
raise NotFound(detail=f"item {item_id} does not exist")
return Response({"id": item_id})
from litestar import Litestar, get
from litestar.exceptions import NotFoundException
from litestar.params import FromPath
@get("/items/{item_id:int}")
async def get_item(item_id: FromPath[int]) -> dict[str, int]:
if item_id != 1:
raise NotFoundException(detail=f"item {item_id} does not exist")
return {"id": item_id}
app = Litestar(route_handlers=[get_item])
Custom exception handlers#
DRF replaces the global exception handler through the EXCEPTION_HANDLER setting
that points to a single callable. Django’s handler404 and handler500
project-level variables point to views for two status codes. Litestar accepts a
mapping from exception class or status code to a handler callable through
exception_handlers={...} on any layer.
Tip
Exception handling is part of Litestar’s layered architecture, so these can be declared on applications, routers, controllers or route handlers.
# settings.py
REST_FRAMEWORK = {"EXCEPTION_HANDLER": "myapp.exceptions.handler"}
# exceptions.py
from rest_framework.views import exception_handler
def handler(exc, context):
if isinstance(exc, ItemNotFoundError):
return Response({"detail": "item not found"}, status=404)
return exception_handler(exc, context)
from litestar import Litestar, Request, Response, get
class ItemNotFoundError(Exception):
pass
def handle_item_not_found(request: Request, exception: ItemNotFoundError) -> Response[dict[str, str]]:
return Response({"detail": "item not found"}, status_code=404)
@get("/")
async def index() -> None:
raise ItemNotFoundError
app = Litestar(
route_handlers=[index],
exception_handlers={ItemNotFoundError: handle_item_not_found},
)
See also
Middleware#
Django’s MIDDLEWARE setting lists dotted-path classes implementing
__init__(get_response) and __call__(request). Litestar middleware is
ASGI-based: subclass ASGIMiddleware and implement
handle(), which receives scope,
receive, send, and a next_app callable. next_app is the rest of the
ASGI stack. Calling it dispatches to the next middleware or the route handler.
Pure ASGI middleware (a callable wrapping an ASGI app and returning a wrapped ASGI app) works in any ASGI framework, so existing ASGI middleware ports over without change.
# middleware.py
import time
class ProcessTimeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
start = time.monotonic()
response = self.get_response(request)
response["X-Process-Time"] = f"{time.monotonic() - start:.4f}"
return response
# settings.py
MIDDLEWARE = [..., "myapp.middleware.ProcessTimeMiddleware"]
import time
from typing import Dict
from litestar import Litestar, get
from litestar.datastructures import MutableScopeHeaders
from litestar.middleware import ASGIMiddleware
from litestar.types import ASGIApp, Message, Receive, Scope, Send
class ProcessTimeMiddleware(ASGIMiddleware):
async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None:
start_time = time.monotonic()
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableScopeHeaders.from_message(message=message)
headers["x-process-time"] = f"{time.monotonic() - start_time:.4f}"
await send(message)
await next_app(scope, receive, send_wrapper)
@get("/")
async def index() -> Dict[str, str]:
return {"hello": "world"}
app = Litestar(route_handlers=[index], middleware=[ProcessTimeMiddleware()])
import time
from litestar import Litestar, get
from litestar.datastructures import MutableScopeHeaders
from litestar.middleware import ASGIMiddleware
from litestar.types import ASGIApp, Message, Receive, Scope, Send
class ProcessTimeMiddleware(ASGIMiddleware):
async def handle(self, scope: Scope, receive: Receive, send: Send, next_app: ASGIApp) -> None:
start_time = time.monotonic()
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableScopeHeaders.from_message(message=message)
headers["x-process-time"] = f"{time.monotonic() - start_time:.4f}"
await send(message)
await next_app(scope, receive, send_wrapper)
@get("/")
async def index() -> dict[str, str]:
return {"hello": "world"}
app = Litestar(route_handlers=[index], middleware=[ProcessTimeMiddleware()])
Django middleware applies to every request that enters the WSGI / ASGI stack; opting routes out means restructuring the middleware itself or splitting URL patterns. Litestar middleware can be excluded per route, in two ways:
By setting
scopeson the middleware, it can restricted tohttporwebsocketrequestsBy setting
exclude_path_patternon the middleware, it can be excluded from any handler matchingexclude_opt_keynames a key in the handler’soptdict; handlers that set that key to a truthy value skip the middleware
class AuthMiddleware(ASGIMiddleware):
exclude = ["/health", "^/internal/"]
exclude_opt_key = "no_auth"
@get("/login", no_auth=True)
async def login() -> dict[str, str]:
...
See also
Signals and lifecycle hooks#
Django signals (pre_save, post_save, request_started,
request_finished, and custom Signal instances) are an in-process
publish/subscribe bus that mixes request lifecycle with domain events.
Litestar exposes the request-lifecycle slice as per-request hooks:
before_request, after_request, after_response, and
after_exception, declared on any layer. They run on the request task and
can read or mutate the request and response.
The request-finished case maps to after_response:
from django.core.signals import request_finished
from django.dispatch import receiver
@receiver(request_finished)
def log_request(sender, **kwargs):
...
import logging
from litestar import Litestar, Request, get
logger = logging.getLogger(__name__)
def attach_user(request: Request) -> None:
request.state["user"] = request.headers.get("x-user", "anonymous")
def log_status(request: Request) -> None:
"""``after_response`` runs after the response body is sent — good for logging."""
logger.info("request to %s by %s", request.url.path, request.state.get("user"))
@get("/hello")
async def hello(request: Request) -> dict[str, str]:
return {"user": request.state["user"]}
app = Litestar(
route_handlers=[hello],
before_request=attach_user,
after_response=log_status,
)
Domain events (post_save, custom Signal instances) have no
first-party Litestar equivalent. For in-process fan-out, call the service
function directly from the handler (or from a dependency it injects). For
cross-process fan-out, publish to a message broker (Redis pub/sub, NATS,
RabbitMQ) through a client wired up on the application.
Background tasks#
Django 6 ships django.tasks, a first-party task-queue interface with a
pluggable backend (in-memory, immediate, database, and third-party Celery /
RQ adapters). Tasks are declared with the @task decorator and submitted
through .enqueue(...). On earlier versions there was no first-party
option; Celery, RQ, django-q, and Huey covered the same ground.
Litestar’s BackgroundTask (or
BackgroundTasks collection) runs a
callable after the response body has been sent, attached to a
Response through background=. The two address
different needs: django.tasks is a durable queue, BackgroundTask is
in-process work that should not block the response.
# tasks.py
from django.tasks import task
@task()
def send_welcome_email(email: str) -> None:
...
# views.py
from .tasks import send_welcome_email
def signup(request):
email = request.POST["email"]
send_welcome_email.enqueue(email)
return JsonResponse({"status": "queued"}, status=201)
import logging
from litestar import Litestar, Response, post
from litestar.background_tasks import BackgroundTask
logger = logging.getLogger(__name__)
async def send_welcome_email(address: str) -> None:
logger.info("sending welcome email to %s", address)
@post("/signup")
async def signup(data: dict[str, str]) -> Response[dict[str, str]]:
return Response(
{"status": "queued"},
background=BackgroundTask(send_welcome_email, data["email"]),
)
app = Litestar(route_handlers=[signup])
A BackgroundTask is not a job queue: it has no durability, no scheduling,
no retries, and no cross-process fan-out. If the worker process dies before
the task finishes, the task is lost. For durable jobs reach for an external
queue.
See also
Database access#
Litestar is ORM-agnostic. There is no built-in ORM and no required integration: SQLAlchemy, Tortoise, Piccolo, Peewee, an async driver, or raw SQL all work without framework cooperation. A handler that opens a connection, runs a query, and returns a dict is a complete Litestar handler.
The fullest first-party integration is the optional
SQLAlchemyPlugin, built on
advanced-alchemy. The plugin
manages the engine and session lifecycle, injects an AsyncSession per
request through the db_session dependency, ships a repository, and,
through SQLAlchemyDTO, serialises
mapped models to JSON natively. Returning an ORM instance directly from a
handler works because the DTO reads the mapped columns and emits the schema
that Litestar uses for OpenAPI and the response body. No ModelSerializer
equivalent is needed.
Other ORMs follow the same shape minus the plugin: register a startup hook that opens the engine, provide a session-yielding dependency, and either return data the model library already serialises (dataclasses, msgspec, Pydantic) or write a thin DTO for the model type.
# models.py
from django.db import models
class Item(models.Model):
name = models.CharField(max_length=100)
# serializers.py
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ["id", "name"]
# views.py
class ItemViewSet(viewsets.ModelViewSet):
queryset = Item.objects.all()
serializer_class = ItemSerializer
from collections.abc import Sequence
from typing import Annotated
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from litestar import Litestar, get, post
from litestar.dto import DTOConfig
from litestar.plugins.sqlalchemy import (
AsyncSessionConfig,
SQLAlchemyAsyncConfig,
SQLAlchemyDTO,
SQLAlchemyPlugin,
)
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "migration_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str]
WriteItemDTO = SQLAlchemyDTO[Annotated[Item, DTOConfig(exclude={"id"})]]
ReadItemDTO = SQLAlchemyDTO[Item]
@post("/items", dto=WriteItemDTO, return_dto=ReadItemDTO)
async def create_item(data: Item, db_session: AsyncSession) -> Item:
async with db_session.begin():
db_session.add(data)
return data
@get("/items", return_dto=ReadItemDTO)
async def list_items(db_session: AsyncSession) -> Sequence[Item]:
return (await db_session.execute(select(Item))).scalars().all()
config = SQLAlchemyAsyncConfig(
connection_string="sqlite+aiosqlite:///:memory:",
create_all=True,
metadata=Base.metadata,
session_config=AsyncSessionConfig(expire_on_commit=False),
)
app = Litestar(
route_handlers=[create_item, list_items],
plugins=[SQLAlchemyPlugin(config=config)],
)
Schema migrations move to Alembic (a SQLAlchemy project) rather than manage.py
makemigrations / migrate. The plugin can run Alembic from the Litestar CLI.
See also
Sessions#
Django’s django.contrib.sessions writes a session cookie and stores data in a
configurable backend (database, cache, file, or signed cookie). Litestar’s
ServerSideSessionConfig writes
the session through a pluggable Store (memory, file,
redis, valkey); CookieBackendConfig
keeps the data in a signed cookie. The middleware exposes
request.session as a dict.
def login(request):
request.session["user"] = "alice"
def whoami(request):
return JsonResponse({"user": request.session.get("user")})
from typing import Any
from litestar import Litestar, Request, get, post
from litestar.middleware.session.server_side import ServerSideSessionConfig
session_config = ServerSideSessionConfig()
@post("/login", sync_to_thread=False)
def login(request: Request) -> None:
request.set_session({"user": "alice"})
@get("/whoami", sync_to_thread=False)
def whoami(request: Request) -> dict[str, Any]:
return {"user": request.session.get("user")}
app = Litestar(
route_handlers=[login, whoami],
middleware=[session_config.middleware],
)
Static files#
Django needs django.contrib.staticfiles in INSTALLED_APPS, a
STATICFILES_DIRS setting, and a collectstatic step before deployment.
Litestar registers a static-file router through
create_static_files_router(). The same configuration
serves files in development and production; there is no separate collection step.
# settings.py
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "assets"]
from pathlib import Path
from litestar import Litestar
from litestar.static_files import create_static_files_router
ASSETS_DIR = Path(__file__).parent / "assets"
def ensure_assets() -> None:
ASSETS_DIR.mkdir(exist_ok=True)
ASSETS_DIR.joinpath("hello.txt").write_text("Hello, world!")
app = Litestar(
route_handlers=[
create_static_files_router(path="/static", directories=[ASSETS_DIR]),
],
on_startup=[ensure_assets],
)
WebSockets and channels#
Django has no first-party WebSocket support; Django Channels adds an ASGI worker, a
routing system, consumer classes, and a channel_layer for pub/sub. Litestar
offers WebSockets as a built-in primitive. There are three handler styles:
websocket(): raw ASGI WebSocket handling; same semantics as a ChannelsAsyncWebsocketConsumer.websocket_listener(): per-message callback that takes and returns typed values; the framework handles accept, the receive loop, and serialisation.websocket_stream(): async generator that pushes messages to the WebSocket; the framework handles accept, the receive loop, and serialisation.
from channels.generic.websocket import AsyncJsonWebsocketConsumer
class EchoConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
await self.accept()
async def receive_json(self, content):
await self.send_json({"echo": content["message"]})
from litestar import Litestar, websocket_listener
@websocket_listener("/ws")
async def echo(data: dict[str, str]) -> dict[str, str]:
return {"echo": data["message"]}
app = Litestar(route_handlers=[echo])
For broadcast and pub/sub patterns (the channel_layer.group_send case)
Litestar provides the channels plugin. It handles
per-channel subscriptions, message history, and inter-process fan-out through a
pluggable backend (memory, redis, asyncpg, psycopg), and can generate WebSocket
route handlers that publish incoming events to subscribed clients.
from litestar import Litestar
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
channels_plugin = ChannelsPlugin(
backend=MemoryChannelsBackend(),
channels=["chat"],
create_ws_route_handlers=True,
)
app = Litestar(plugins=[channels_plugin])
See also
Rate limiting#
DRF’s throttle_classes (UserRateThrottle, AnonRateThrottle,
ScopedRateThrottle) limits per user, IP, or named scope through a cache backend.
Litestar’s RateLimitConfig installs
RateLimitMiddleware with a pluggable store.
# settings.py
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": ["rest_framework.throttling.UserRateThrottle"],
"DEFAULT_THROTTLE_RATES": {"user": "60/minute"},
}
from litestar import Litestar, MediaType, get
from litestar.middleware.rate_limit import RateLimitConfig
rate_limit_config = RateLimitConfig(rate_limit=("minute", 60), exclude=["/schema"])
@get("/", media_type=MediaType.TEXT)
async def handler() -> str:
return "ok"
app = Litestar(
route_handlers=[handler],
middleware=[rate_limit_config.middleware],
)
Testing#
Django’s TestCase + Client and DRF’s APITestCase + APIClient send
requests to the WSGI app in-process. Litestar ships
TestClient (sync) and
AsyncTestClient (async), both built on httpx. For
unit-level handler tests, create_test_client() and
create_async_test_client() take the same arguments as
Litestar and return a configured client.
from rest_framework.test import APITestCase
class HelloTests(APITestCase):
def test_index(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"hello": "world"})
from typing import Dict
from litestar import get
from litestar.status_codes import HTTP_200_OK
from litestar.testing import create_test_client
@get("/")
async def hello() -> Dict[str, str]:
return {"hello": "world"}
def test_hello() -> None:
with create_test_client(route_handlers=[hello]) as client:
response = client.get("/")
assert response.status_code == HTTP_200_OK
assert response.json() == {"hello": "world"}
from litestar import get
from litestar.status_codes import HTTP_200_OK
from litestar.testing import create_test_client
@get("/")
async def hello() -> dict[str, str]:
return {"hello": "world"}
def test_hello() -> None:
with create_test_client(route_handlers=[hello]) as client:
response = client.get("/")
assert response.status_code == HTTP_200_OK
assert response.json() == {"hello": "world"}
See also
OpenAPI and the browsable API#
DRF generates an OpenAPI schema from view-and-serializer introspection and renders
an HTML “browsable API” for sessioned users. Litestar generates OpenAPI from
handler signatures and return types and serves multiple UIs out of the box: Swagger,
ReDoc, Scalar, RapiDoc, Stoplight Elements. Per-handler metadata sits on the
decorator (tags, summary, description, operation_id); application-wide
options live on OpenAPIConfig, passed as
openapi_config= to the application.
from drf_spectacular.utils import extend_schema
class ItemView(APIView):
@extend_schema(
tags=["items"],
summary="Retrieve an item",
description="Look up a single item by its numeric identifier.",
operation_id="get_item_by_id",
)
def get(self, request, item_id):
return Response({"id": item_id})
from litestar import Litestar, get
from litestar.openapi.config import OpenAPIConfig
from litestar.params import FromPath
@get(
"/items/{item_id:int}",
tags=["items"],
summary="Retrieve an item",
description="Look up a single item by its numeric identifier.",
operation_id="get_item_by_id",
)
async def get_item(item_id: FromPath[int]) -> dict[str, int]:
return {"id": item_id}
app = Litestar(
route_handlers=[get_item],
openapi_config=OpenAPIConfig(title="Items API", version="1.0.0"),
)
See also
Concepts without a direct equivalent#
A handful of Django features have no first-party Litestar counterpart.
Django admin. Litestar does not ship an admin interface. Community projects such as Starlette-Admin and SQLAdmin work with any ASGI framework and can be mounted on a Litestar app.
Management commands ( manage.py ). Litestar has no equivalent of
django-admin / manage.py, but plugins can register subcommands on the
litestar CLI through the CLIPluginProtocol; for
one-off scripts, a plain click or typer script is the idiomatic choice.
Apps and AppConfig . Litestar has no per-app module system. The plugin
protocols (InitPluginProtocol,
SerializationPluginProtocol,
OpenAPISchemaPluginProtocol,
CLIPluginProtocol,
DIPlugin) cover the same ground: package up a feature
set (handlers, middleware, dependencies, CLI commands, schema customisations) and
register it on the application.
Django Forms (HTML). Litestar does not include HTML-rendering form scaffolding. For HTML-form-driven UIs, render forms with the template engine and validate the posted body with a DTO or model library.
``get_object_or_404`` and model managers. No first-party equivalent. The
SQLAlchemy repository’s get_one_or_none paired with a raised
NotFoundException is the typical pattern.
Further reading#
These are not the only differences. The following pages cover features the guide did not explore in depth:
Quick reference#
A lookup table for the most common translations.
Concept |
Django / DRF |
Litestar |
|---|---|---|
Route declaration |
|
|
Class-based view |
|
|
URL include |
|
|
Application config |
|
|
Synchronous handler |
sync by default |
|
Path parameter |
|
|
Query parameter |
|
|
JSON body |
|
|
Form data |
|
|
File upload |
|
|
Serializer |
|
DTO ( |
Default POST status |
|
|
Default DELETE status |
|
|
Cookies |
|
|
Templates |
|
|
URL reverse |
|
|
Exception |
|
|
Exception handler |
|
|
Authentication |
|
auth middleware or |
Permissions |
|
|
CSRF |
|
|
Middleware |
|
|
Per-request hook |
|
|
Domain event |
|
|
Background task |
Celery |
|
ORM |
Django ORM |
SQLAlchemy + |
Migrations |
|
Alembic |
Sessions |
|
|
Static files |
|
|
WebSockets |
Django Channels consumer |
|
Pub/sub |
Channels |
|
Throttling |
|
|
Test client |
|
|
OpenAPI schema |
drf-spectacular / |
per-decorator keywords + |
Browsable API |
DRF HTML renderer |
Swagger / ReDoc / Scalar / RapiDoc |
Pagination |
|
|
Admin |
|
no equivalent (Starlette-Admin, SQLAdmin) |
Management commands |
|
no equivalent (Click / Typer scripts) |
Apps |
|
plugins |