Skip to content

Responses

This guide covers all the response types available in Django-Bolt and how to use them.

JSON responses

Returning a dict, list, or msgspec.Struct automatically creates a JSON response:

@api.get("/data")
async def get_data():
    return {"message": "Hello", "count": 42}

@api.get("/items")
async def get_items():
    return [{"id": 1}, {"id": 2}]

Custom status codes and headers

Use the JSON class for more control:

from django_bolt import JSON

@api.post("/users")
async def create_user():
    return JSON(
        {"id": 1, "username": "john"},
        status_code=201,
        headers={"X-Created-By": "django-bolt"}
    )

Status code constants and helpers

You can use built-in status code constants for readability:

from django_bolt import status

@api.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user():
    ...

Code classification helpers are also available:

if status.is_client_error(response.status_code):
    ...

if status.is_server_error(response.status_code):
    ...

Plain text

Return plain text responses:

from django_bolt.responses import PlainText

@api.get("/hello")
async def hello():
    return PlainText("Hello, World!")

@api.get("/status")
async def status():
    return PlainText("OK", status_code=200, headers={"X-Status": "healthy"})

HTML

Return HTML content:

from django_bolt.responses import HTML

@api.get("/page")
async def page():
    return HTML("<h1>Welcome</h1><p>This is HTML content.</p>")

@api.get("/template")
async def template():
    html = """
    <!DOCTYPE html>
    <html>
    <head><title>My Page</title></head>
    <body><h1>Hello</h1></body>
    </html>
    """
    return HTML(html)

Django templates

Use the render() function to render Django templates. It works like Django's render() shortcut:

from django_bolt import Request
from django_bolt.shortcuts import render

@api.get("/page")
async def show_page(request: Request):
    return render(request, "myapp/page.html", {
        "title": "My Page",
        "items": ["item1", "item2"],
    })

The auth context processor gives the template the user of the request. In an async handler, a template that reads {{ user }} loads the user, and the event loop waits for the query. To not block the loop, load the user first:

@api.get("/account")
async def account(request: Request):
    await request.auser()  # the template then reads the loaded user
    return render(request, "myapp/account.html")

Use standard Django templates - nothing special required:

<!-- templates/myapp/page.html -->
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
    <h1>{{ title }}</h1>
    <ul>
    {% for item in items %}
        <li>{{ item }}</li>
    {% endfor %}
    </ul>
</body>
</html>

Parameters:

Parameter Type Default Description
request Request required The request object
template_name str required Path to the template file
context dict None Template context variables
content_type str None Response content type
status int 200 HTTP status code
using str None Template engine to use

Redirects

Redirect to another URL:

from django_bolt.responses import Redirect

@api.get("/old-page")
async def old_page():
    return Redirect("/new-page")

@api.get("/external")
async def external():
    return Redirect("https://example.com", status_code=302)

Redirect status codes:

  • 301 - Permanent redirect
  • 302 - Temporary redirect (Found)
  • 303 - See Other
  • 307 - Temporary redirect (default, preserves method)
  • 308 - Permanent redirect (preserves method)

File downloads

In-memory files

Use File for small files that can be loaded into memory:

from django_bolt.responses import File

@api.get("/download")
async def download():
    return File(
        "/path/to/file.pdf",
        filename="document.pdf",
        media_type="application/pdf"
    )

Streaming files

Use FileResponse for larger files that should be streamed:

from django_bolt.responses import FileResponse

@api.get("/video")
async def video():
    return FileResponse(
        "/path/to/video.mp4",
        filename="video.mp4",
        media_type="video/mp4"
    )

FileResponse streams the file directly without loading it entirely into memory.

File security

Configure allowed directories in settings.py:

BOLT_ALLOWED_FILE_PATHS = [
    "/var/app/uploads",
    "/var/app/public",
]

When configured, FileResponse only serves files within these directories, preventing path traversal attacks.

A FileResponse for a path outside these directories raises ResponseFileNotAllowed, and Bolt answers 403, also when the path names no file. When the lookup fails, Bolt follows the links of the path, as realpath does, to where the lookup stops. Then it completes the path from there as text. A FileResponse for a path that names no regular file raises ResponseFileNotFound, and Bolt answers 404. Examples are a missing file, a directory and a path that is too long. The path often comes from the request, so a client does not get a 500 for it. The errors subclass PermissionError and FileNotFoundError. The 404 and 403 responses do not show the path. Bolt answers 500 for a FileNotFoundError or a PermissionError of other code, because it is a server fault.

Streaming responses

Stream data incrementally using generators:

from django_bolt import StreamingResponse

@api.get("/stream")
async def stream():
    def generate():
        for i in range(100):
            yield f"chunk {i}\n"

    return StreamingResponse(generate(), media_type="text/plain")

Async generators

For async operations, use async generators:

import asyncio

@api.get("/async-stream")
async def async_stream():
    async def generate():
        for i in range(10):
            await asyncio.sleep(0.1)
            yield f"data: {i}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

Errors in a stream

Bolt sends the status and the headers before the first chunk. Thus an error in the generator cannot become a 500. The body ends at the error, and the client gets the chunks before it. Bolt logs the error with its traceback to the django_bolt.streaming logger, at level ERROR.

Server-Sent Events (SSE)

For SSE endpoints, see the dedicated Server-Sent Events guide. Django-Bolt provides EventSourceResponse with automatic SSE framing, compression skipping, and keep-alive pings:

from collections.abc import AsyncIterable
from django_bolt.responses import EventSourceResponse

@api.get("/events", response_class=EventSourceResponse)
async def events() -> AsyncIterable[dict]:
    for i in range(10):
        yield {"count": i}
        await asyncio.sleep(1)

Response with custom headers

Use the Response class for complete control:

from django_bolt import Response

@api.options("/items")
async def options_items():
    return Response(
        {},
        status_code=204,
        headers={"Allow": "GET, POST, PUT, DELETE"}
    )

Setting cookies

Use the .set_cookie() method on any response type:

from django_bolt import Response, JSON

@api.post("/login")
async def login():
    return Response({"logged_in": True}).set_cookie("session", "abc123")

Bolt writes the value as Django set_cookie writes it. A value with a space, a quote, ; or another unsafe character gets double quotes and escapes. request.cookies and Cookie() remove them again, so a handler reads the value that Bolt sets. Bolt does not set a value that has a control character or a character above U+00FF, and logs a warning. Django cannot send these values either. To keep other text in a cookie, encode it first, for example with urllib.parse.quote().

Pass additional options to control cookie behavior:

@api.post("/login")
async def login():
    return JSON({"message": "Logged in"}).set_cookie(
        name="session",
        value="abc123",
        max_age=3600,           # Expires in 3600 seconds
        path="/",               # Cookie path (default: "/")
        domain=".example.com",  # Cookie domain
        secure=True,            # Only send over HTTPS
        httponly=True,          # Not accessible via JavaScript
        samesite="Lax",         # CSRF protection: "Strict", "Lax", or "None"
    )

Cookie attributes:

Attribute Type Default Description
name str required Cookie name
value str "" Cookie value
max_age int None Seconds until expiration
expires datetime \| str None Expiration date
path str "/" Cookie path
domain str None Cookie domain
secure bool False HTTPS only
httponly bool False No JavaScript access
samesite str \| False "Lax" "Strict", "Lax", "None", or False to omit

Multiple cookies

Chain multiple .set_cookie() calls:

@api.post("/login")
async def login():
    return Response({"logged_in": True}) \
        .set_cookie("session", "abc123", httponly=True) \
        .set_cookie("preferences", "dark-mode", max_age=86400 * 365)

Deleting cookies

Use .delete_cookie() to remove a cookie:

@api.post("/logout")
async def logout():
    return Response({"logged_out": True}).delete_cookie("session")

With path and domain (must match the original cookie):

@api.post("/logout")
async def logout():
    return Response({"logged_out": True}).delete_cookie(
        "session",
        path="/app",
        domain=".example.com"
    )

A name that starts with __Secure- or __Host- gets the Secure flag, as in Django. A browser ignores the delete of such a cookie without this flag.

Set and delete in one response

@api.post("/refresh")
async def refresh():
    return Response({"refreshed": True}) \
        .delete_cookie("old_session") \
        .set_cookie("new_session", "xyz789", httponly=True)

Cookies on all response types

The .set_cookie() and .delete_cookie() methods work on all response types:

from django_bolt import Response, JSON, StreamingResponse
from django_bolt.responses import HTML, PlainText, Redirect

# JSON
JSON({"ok": True}).set_cookie("api_token", "secret")

# HTML
HTML("<h1>Welcome</h1>").set_cookie("visited", "true")

# PlainText
PlainText("OK").set_cookie("status", "checked")

# Redirect with cookie (e.g., after login)
Redirect("/dashboard").set_cookie("just_logged_in", "1", max_age=5)

# Streaming response
StreamingResponse(generate()).set_cookie("stream_id", "12345")

For advanced use cases, create Cookie objects directly:

from django_bolt import Cookie

cookie = Cookie(
    name="session",
    value="abc123",
    max_age=3600,
    secure=True,
    httponly=True,
    samesite="Strict",
)

# Get the Set-Cookie header value
header_value = cookie.to_header_value()
# "session=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Strict"

Response validation

Validate response data against a schema using response_model:

import msgspec

class User(msgspec.Struct):
    id: int
    username: str
    email: str

@api.get("/users/{user_id}", response_model=User)
async def get_user(user_id: int):
    return {"id": user_id, "username": "john", "email": "john@example.com"}

If the response doesn't match the schema, a 500 error is returned.

You can also use return type annotations:

@api.get("/users/{user_id}")
async def get_user(user_id: int) -> User:
    return {"id": user_id, "username": "john", "email": "john@example.com"}

The schema describes the body of the default status code of the route. Bolt validates a JSON or Response only when its status code is that default. A response with another status code, such as a 400 with a map of field errors, is encoded as-is. To document other status codes, use the per-status-code form below. It does not validate them either.

@api.post("/users", status_code=201)
async def create_user(body: UserCreate) -> User:
    if await username_taken(body.username):
        return JSON({"username": ["Already taken."]}, status_code=400)  # not validated
    return JSON(await create(body), status_code=201)  # validated as User

Per-status-code response schemas

response_model also accepts a dict mapping status codes to types. Each code gets its own OpenAPI response entry. Bolt encodes the body as-is and does not validate it.

import msgspec

class Item(msgspec.Struct):
    id: int
    name: str

class Error(msgspec.Struct):
    detail: str

@api.get("/items/{item_id}", response_model={200: Item, 404: Error})
async def get_item(item_id: int):
    item = await find_item(item_id)
    if item is None:
        return 404, {"detail": "Item not found"}
    return 200, {"id": item.id, "name": item.name}

Tuple return — Return (status_code, data) to select the schema by code:

return 200, {"id": 1, "name": "Alice"}   # documented as Item
return 404, {"detail": "Not found"}       # documented as Error

JSON() return — JSON(data, status_code=...) also selects the schema:

return JSON({"detail": "Not found"}, status_code=404)

Bare return — A plain dict or list uses the default status code (lowest 2xx in the map):

@api.get("/items", response_model={200: list[Item], 400: Error})
async def list_items():
    return [{"id": 1, "name": "Alice"}]  # documented as list[Item] at 200

204 No Content — Use {204: None} with return (204, None) for empty body responses:

@api.delete("/items/{item_id}", response_model={204: None, 404: Error})
async def delete_item(item_id: int):
    deleted = await try_delete(item_id)
    if not deleted:
        return 404, {"detail": "Item not found"}
    return 204, None

Ellipsis catch-all — Use ... as a key to document any unmapped status code:

@api.get("/items/{item_id}", response_model={200: Item, ...: Error})
async def get_item(item_id: int):
    ...  # any non-200 status code is documented as Error

Explicit status_code — Override the auto-detected default status code:

@api.post("/items", response_model={201: Item, 400: Error}, status_code=201)
async def create_item():
    return {"id": 1, "name": "New"}  # bare return uses 201 instead of auto-detected

Typed error bodies — Raise HTTPException with body= to send a typed body from anywhere. The handler return annotation stays the success type, so type checkers can check it:

@api.post("/plans/{pk}/start", response_model={200: Plan, 400: Error})
async def start_plan(request, pk: int) -> Plan:
    if not healthy:
        raise HTTPException(400, body=Error(detail="not healthy"))
    return Plan(id=pk)

body replaces the default {"detail": ...} envelope. Bolt encodes it as-is and does not validate it. An unmapped status code is sent as-is too.

Setting default status codes

Set a default status code for an endpoint:

@api.post("/users", status_code=201)
async def create_user():
    return {"id": 1, "username": "john"}

OpenAPI documents this status code even when the handler has no response annotation.

Returning strings and bytes

Returning a string creates a plain text response:

@api.get("/text")
async def text():
    return "Hello"  # Content-Type: text/plain

Returning bytes creates an octet-stream response:

@api.get("/bytes")
async def bytes_response():
    return b"binary data"  # Content-Type: application/octet-stream

Empty responses

For 204 No Content responses:

from django_bolt import Response

@api.delete("/items/{item_id}", status_code=204)
async def delete_item(item_id: int):
    # ... delete the item ...
    return Response(status_code=204)