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:
    ...

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=True offloads 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=False runs 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.

Cookies and headers#

Django sets cookies and headers on the response object (response.set_cookie(...), response["X-Foo"] = "bar"). DRF works the same. Litestar offers two paths: declare static values on the decorator with response_cookies= and response_headers=, or return a Response with cookies= and headers= when the values depend on the request.

Tip

Setting a static response header or cookie will automatically render it in the OpenAPI schema

def view(request):
    response = JsonResponse({"set": "dynamic"})
    response.set_cookie("my-cookie", "cookie-value")
    return response
from litestar import Litestar, Response, get
from litestar.datastructures import Cookie


@get("/static", response_cookies=[Cookie(key="my-cookie", value="cookie-value")])
async def static_cookie() -> dict[str, str]:
    return {"set": "static"}


@get("/dynamic")
async def dynamic_cookie() -> Response[dict[str, str]]:
    return Response(
        {"set": "dynamic"},
        cookies=[Cookie(key="my-cookie", value="cookie-value")],
    )


app = Litestar(route_handlers=[static_cookie, dynamic_cookie])

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.

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.

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},
)

Authentication and authorization#

DRF separates authentication (who is the caller) from permissions (is the caller allowed to perform this action). Litestar separates them the same way: authentication middleware sets connection.user and connection.auth; guards decide whether a request reaches the handler.

Authentication#

DRF’s authentication classes (SessionAuthentication, TokenAuthentication, JWTAuthentication) inspect each request and populate request.user. Litestar uses authentication middleware. Subclass AbstractAuthenticationMiddleware for custom schemes, or use the bundled JWTAuth / SessionAuth configs that ship a middleware and OpenAPI security scheme together.

class BearerAuthentication(BaseAuthentication):
    def authenticate(self, request):
        header = request.headers.get("Authorization", "")
        if header != "Bearer secret":
            raise AuthenticationFailed()
        return (AnonymousUser(), header)

class IndexView(APIView):
    authentication_classes = [BearerAuthentication]

    def get(self, request):
        return Response({"hello": "world"})
from typing import Dict

from litestar import Litestar, get
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.handlers import BaseRouteHandler


async def require_token(connection: ASGIConnection, route_handler: BaseRouteHandler) -> None:
    if connection.headers.get("authorization") != "Bearer secret":
        raise NotAuthorizedException


@get("/", guards=[require_token])
async def index() -> Dict[str, str]:
    return {"hello": "world"}


app = Litestar(route_handlers=[index])
from litestar import Litestar, get
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.handlers import BaseRouteHandler


async def require_token(connection: ASGIConnection, route_handler: BaseRouteHandler) -> None:
    if connection.headers.get("authorization") != "Bearer secret":
        raise NotAuthorizedException


@get("/", guards=[require_token])
async def index() -> dict[str, str]:
    return {"hello": "world"}


app = Litestar(route_handlers=[index])

For session-cookie auth or JWT, register the corresponding config through on_app_init; see Security.

Guards#

DRF’s permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] runs a list of permission callables that return True / False per request. Litestar’s guards are callables that receive the ASGIConnection and the BaseRouteHandler; raising an exception aborts the request.

Info

A guard is a callable that receives the ASGIConnection and the BaseRouteHandler; raising an exception here aborts the request.

Tip

Guards are part of Litestar’s layered architecture, so these can be declared on applications, routers, controllers or route handlers.

class IsAdmin(BasePermission):
    def has_permission(self, request, view):
        return request.headers.get("X-Role") == "admin"

class AdminViewSet(viewsets.ViewSet):
    permission_classes = [IsAdmin]
    ...
from typing import Dict

from litestar import Controller, Litestar, get
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.handlers import BaseRouteHandler


def require_admin(connection: ASGIConnection, route_handler: BaseRouteHandler) -> None:
    if connection.headers.get("x-role") != "admin":
        raise NotAuthorizedException(detail="admin only")


class AdminController(Controller):
    path = "/admin"
    guards = [require_admin]

    @get("/stats")
    async def stats(self) -> Dict[str, int]:
        return {"users": 100}


app = Litestar(route_handlers=[AdminController])
from litestar import Controller, Litestar, get
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.handlers import BaseRouteHandler


def require_admin(connection: ASGIConnection, route_handler: BaseRouteHandler) -> None:
    if connection.headers.get("x-role") != "admin":
        raise NotAuthorizedException(detail="admin only")


class AdminController(Controller):
    path = "/admin"
    guards = [require_admin]

    @get("/stats")
    async def stats(self) -> dict[str, int]:
        return {"users": 100}


app = Litestar(route_handlers=[AdminController])

CSRF#

Django’s CsrfViewMiddleware reads the csrftoken cookie and validates the X-CSRFToken header on unsafe methods. DRF inherits this behavior for session-authenticated views. Litestar provides CSRFConfig passed through csrf_config= on the application; the default cookie and header names match Django’s convention.

from litestar import Litestar, post
from litestar.config.csrf import CSRFConfig


@post("/transfer")
async def transfer(data: dict[str, int]) -> dict[str, int]:
    return data


app = Litestar(
    route_handlers=[transfer],
    csrf_config=CSRFConfig(secret="change-me"),
)

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 scopes on the middleware, it can restricted to http or websocket requests

  • By setting exclude_path_pattern on the middleware, it can be excluded from any handler matching

  • exclude_opt_key names a key in the handler’s opt dict; 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]:
    ...

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 Channels AsyncWebsocketConsumer.

  • 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

path("", views.index) in urls.py

@get("/") + Litestar([handler])

Class-based view

APIView / ViewSet

Controller subclass

URL include

include("app.urls")

Router(path=..., route_handlers=[...])

Application config

settings.py

Litestar(...) constructor

Synchronous handler

sync by default

@get(sync_to_thread=True)

Path parameter

<int:item_id> + view kwarg

{item_id:int} + FromPath[int]

Query parameter

request.GET.get("x")

FromQuery[T]

JSON body

serializer = S(data=request.data)

data: Item

Form data

request.POST

Body(media_type=URL_ENCODED)

File upload

request.FILES["data"]

UploadFile + Body(MULTI_PART)

Serializer

ModelSerializer

DTO (SQLAlchemyDTO, DataclassDTO)

Default POST status

200

201

Default DELETE status

200

204

Cookies

response.set_cookie(...)

response_cookies=[Cookie(...)]

Templates

render(request, "...", ctx)

Template() + TemplateConfig

URL reverse

reverse("name", kwargs={...})

request.url_for("name", ...)

Exception

Http404, raise NotFound(...)

raise NotFoundException(...)

Exception handler

EXCEPTION_HANDLER setting

exception_handlers={Exc: handler}

Authentication

authentication_classes = [...]

auth middleware or JWTAuth

Permissions

permission_classes = [...]

guards=[...]

CSRF

CsrfViewMiddleware

csrf_config=CSRFConfig(...)

Middleware

MIDDLEWARE = [...]

ASGIMiddleware subclass

Per-request hook

request_finished signal

after_response=fn

Domain event

post_save signal

@listener + app.emit

Background task

Celery task.delay(...)

Response(background=BackgroundTask)

ORM

Django ORM

SQLAlchemy + SQLAlchemyPlugin

Migrations

manage.py makemigrations

Alembic

Sessions

django.contrib.sessions

ServerSideSessionConfig + store

Static files

staticfiles + collectstatic

create_static_files_router(...)

WebSockets

Django Channels consumer

@websocket_listener("/ws")

Pub/sub

Channels channel_layer.group_send

ChannelsPlugin

Throttling

throttle_classes = [...]

RateLimitConfig(...)

Test client

APIClient / Client

TestClient / create_test_client

OpenAPI schema

drf-spectacular / @extend_schema

per-decorator keywords + OpenAPIConfig

Browsable API

DRF HTML renderer

Swagger / ReDoc / Scalar / RapiDoc

Pagination

pagination_class = ...

OffsetPagination[T] return type

Admin

django.contrib.admin

no equivalent (Starlette-Admin, SQLAdmin)

Management commands

manage.py

no equivalent (Click / Typer scripts)

Apps

AppConfig / INSTALLED_APPS

plugins