Skip to content

Responses

Lilya, by design, furnishes specific response classes that serve a dual purpose. They offer utility and are tasked with sending the appropriate ASGI messages through the send channel.

Lilya automatically includes the Content-Length and Content-Type headers.

How does it work

There are a few ways of using the responses within a Lylia application.

Available responses

All the responses from Lilya inherit from the parent object Response and that same class can also be used directly.

All the responses are considered ASGI applications, which means you can treat them as such in your application if necessary.

Example

from lilya.responses import PlaiText
from lilya.types import Scope, Receive, Send


async def asgi_app(scope: Scope, receive: Receive, send: Send):
    assert scope['type'] == 'http'
    response = PlaiText('Welcome')
    await response(scope, receive, send)

Response

from lilya.responses import Response

Example

from lilya.apps import Lilya
from lilya.responses import Response
from lilya.routing import Path


def home():
    return Response("Welcome home")


app = Lilya(routes=[Path("/", home)])

Lilya provides the set_cookie that allows settings a cookie on a given response. All the responses available in Lilya have access to this functionality.

from lilya.responses import Response
from lilya.types import Scope, Receive, Send


async def asgi_app(scope: Scope, receive: Receive, send: Send):
    assert scope['type'] == 'http'
    response = Response('Welcome', media_type='text/plain')

    response.set_cookie(key=..., value=..., max_age=..., expires=...,)
    await response(scope, receive, send)
Parameters

The available parameters of the set_cookie are as follow:

  • key - A string representing the cookie's key.
  • value - A string representing the cookie's value.
  • max_age - An integer defining the cookie's lifetime in seconds. A negative value or 0 discards the cookie immediately. (Optional)
  • expires - Either an integer indicating the seconds until the cookie expires or a datetime. (Optional)
  • path - A string specifying the subset of routes to which the cookie applies. (Optional)
  • domain - A string specifying the valid domain for the cookie. (Optional)
  • secure - A boolean indicating that the cookie is sent to the server only if the request uses SSL and the HTTPS protocol. (Optional)
  • httponly - A boolean indicating that the cookie is inaccessible via JavaScript through Document.cookie, the XMLHttpRequest, or Request APIs. (Optional)
  • samesite - A string specifying the samesite strategy for the cookie, with valid values of 'lax', 'strict', and 'none'. Defaults to 'lax'. (Optional)

In the same fashion as the set cookie, this function is available on every response provided by Lilya.

from lilya.responses import Response
from lilya.types import Scope, Receive, Send


async def asgi_app(scope: Scope, receive: Receive, send: Send):
    assert scope['type'] == 'http'
    response = Response('Welcome', media_type='text/plain')

    response.delete_cookie(key=..., path=..., domain=...)
    await response(scope, receive, send)
Parameters

The available parameters of the set_cookie are as follow:

  • key - A string representing the cookie's key.
  • path - A string specifying the subset of routes to which the cookie applies. (Optional)
  • domain - A string specifying the valid domain for the cookie. (Optional)

HTMLResponse

Returning an html response.

from lilya.responses import HTMLResponse

Example

from lilya.apps import Lilya
from lilya.responses import HTMLResponse
from lilya.routing import Path


def home():
    return HTMLResponse("<html><body><p>Welcome!</p></body></html>")


app = Lilya(routes=[Path("/", home)])

HTML

Returning an html response. This is literally the same as the HTMLResponse but created for better readability.

from lilya.responses import HTML

Error

Response that can be used when throwing a 500 error. Defaults to return an html response.

from lilya.responses import Error

Example

from lilya.apps import Lilya
from lilya.responses import Error
from lilya.routing import Path


def home():
    return Error("<html><body><p>Error!</p></body></html>")


app = Lilya(routes=[Path("/", home)])

PlainText (You can also use TextResponse as alias)

Response that can be used to return text/plain.

from lilya.responses import PlainText

Example

from lilya.apps import Lilya
from lilya.responses import PlainText
from lilya.routing import Path


def home():
    return PlainText("Welcome home")


app = Lilya(routes=[Path("/", home)])

JSONResponse

Response that can be used to return application/json.

from lilya.responses import JSONResponse

Example

from lilya.apps import Lilya
from lilya.responses import JSONResponse
from lilya.routing import Path


def home():
    return JSONResponse({"message": "Welcome home"})


app = Lilya(routes=[Path("/", home)])

Ok

Response that can be used to return application/json as well. You can see this as an alternative to JSONResponse.

from lilya.responses import Ok

Example

from lilya.apps import Lilya
from lilya.responses import Ok
from lilya.routing import Path


def home():
    return Ok({"message": "Welcome home"})


app = Lilya(routes=[Path("/", home)])

RedirectResponse

Used for redirecting the responses.

from lilya.responses import RedirectResponse

Example

from lilya.apps import Lilya
from lilya.responses import RedirectResponse
from lilya.routing import Path


def home():
    return RedirectResponse(url="/another-url")


app = Lilya(routes=[Path("/", home)])

StreamingResponse

from lilya.responses import StreamingResponse

Example

from collections.abc import Generator

from lilya.apps import Lilya
from lilya.responses import StreamingResponse
from lilya.routing import Path


def my_generator() -> Generator[str, None, None]:
    count = 0
    while True:
        count += 1
        yield str(count)


def home():
    return StreamingResponse(my_generator(), media_type="text/html")


app = Lilya(routes=[Path("/", home)])

FileResponse

from lilya.responses import FileResponse

Streams a file asynchronously as the response, employing a distinct set of arguments for instantiation compared to other response types:

  • path - The filepath to the file to stream.
  • status_code - The Status code to return.
  • headers - Custom headers to include, provided as a dictionary.
  • media_type - A string specifying the media type. If unspecified, the filename or path is used to deduce the media type.
  • filename - If specified, included in the response Content-Disposition.
  • content_disposition_type - Included in the response Content-Disposition. Can be set to attachment (default) or inline.
  • background - A task instance.
  • allow_range_requests - Should enable support for http ranges? By default True. You certainly want this for continuing downloads.
  • range_multipart_boundary - Enable multipart http ranges. Either bool or explicit string value used for the boundary. By default False (multipart is disabled).

Example

from lilya.apps import Lilya
from lilya.responses import FileResponse
from lilya.routing import Path


def home():
    return FileResponse(
        "files/something.csv",
        filename="something",
    )


app = Lilya(routes=[Path("/", home)])

By default multipart ranges are disabled as it is a bit more expensive (cpu and data usage), you can enable it by setting range_multipart_boundary to True or an explicit boundary value.

from lilya.apps import Lilya
from lilya.responses import FileResponse
from lilya.routing import Path


def home():
    return FileResponse(
        "files/something.csv",
        filename="something",
        range_multipart_boundary=True
    )

# or alternatively provide an explicit boundary
def home():
    return FileResponse(
        "files/something.csv",
        filename="something",
        range_multipart_boundary="1234abc"
    )



app = Lilya(routes=[Path("/", home)])

By default we limit the maximum amount of requested ranges to five. For a different security approach or different multipart parsing you can modify the FileResponse

from typing import Any

from lilya.apps import Lilya
from lilya.responses import FileResponse
from lilya.routing import Path
from lilya.types import Scope
from lilya.ranges import ContentRanges

class CustomFileResponse(FileResponse):
    def get_content_ranges_and_multipart(
        self, scope: Scope, /, **kwargs: Any
    ) -> tuple[ContentRanges | None, bool]:
        kwargs.setdefault("enforce_asc", False)
        # unlimit the amount of requested ranges and do security later
        kwargs.setdefault("max_ranges", None if self.range_multipart_boundary else 1)
        content_ranges, multipart = super().get_content_ranges_and_multipart(scope, **kwargs)

        # check that ranges are not too small, resource abuse by using protocol overhead
        # Note: we have already a mitigation in place by allowing only strictly ascending orders even with enforce_asc=False
        # enforce_asc=False is more lenient by modifying the ranges
        for range_def in content_ranges.ranges:
            # ranges must be at least 50 bytes otherwise the whole response is returned
            if range_def.stop - range_def.start +1 <= 50:
                # returning (None, ...) causes a whole response to be returned instead of a partial response
                return None, False

        # allow unordered ranges
        # allow single range responses on multi range requests (spec violation some clients does not support)
        # by default multipart is used when a "," is in the range header
        return content_ranges, len(content_ranges.ranges) > 1

def home() -> CustomFileResponse:
    return CustomFileResponse(
        "files/something.csv",
        filename="something",
    )


app = Lilya(routes=[Path("/", home)])

Note however that some clients doesn't behave well (or just fallback to non-range download) if multi-range requests are answered with a single range response and vice versa.

CSVResponse

Response used to return data in CSV (Comma-Separated Values) format.

from lilya.responses import CSVResponse

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import CSVResponse


async def export_data():
    data = [
        {"name": "Lilya", "age": 35},
        {"name": "Maria", "age": 28},
    ]
    return CSVResponse(content=data)

app = Lilya(
    routes=[Path("/export", export_data)]
)

This will return a CSV response with the following content:

name,age
Lilya,35
Maria,28

How it works

The CSVResponse converts an iterable of dictionaries into a properly formatted CSV string. Each dictionary represents one row, and its keys define the column headers.

If the iterable is empty or None, an empty body (b"") is returned.

Arguments

  • content — An iterable of dictionaries (Iterable[dict[str, Any]]) representing rows in the CSV.
  • status_code — Optional HTTP status code. Defaults to 200.
  • headers — Optional custom headers.
  • media_type — Always "text/csv" by default.
  • charset — Defaults to "utf-8".

Example with custom headers

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import CSVResponse


async def download():
    users = [
        {"username": "admin", "email": "admin@example.com"},
        {"username": "guest", "email": "guest@example.com"},
    ]
    headers = {
        "Content-Disposition": 'attachment; filename="users.csv"'
    }
    return CSVResponse(content=users, headers=headers)

app = Lilya(
    routes=[Path("/download", download)]
)

The above endpoint prompts the browser to download a file named users.csv.

XMLResponse

Response used to return data in XML format.

from lilya.responses import XMLResponse

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import XMLResponse


async def feed():
    data = {"person": {"name": "Lilya", "age": 35}}
    return XMLResponse(content=data)


app = Lilya(
    routes=[Path("/feed", feed)]
)

This will return an XML response with:

<root><person><name>Lilya</name><age>35</age></person></root>

How it works

The XMLResponse converts common Python data structures (dict, list, str, bytes) into XML. Each dictionary key becomes a tag name, and nested dictionaries or lists are converted recursively.

Arguments

  • content — The data to serialize into XML. Supports dict, list, str, or bytes.
  • status_code — Optional HTTP status code. Defaults to 200.
  • headers — Optional custom headers.
  • media_type — Always "application/xml".
  • charset — Defaults to "utf-8".

Example with list content

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import XMLResponse


async def items():
    data = [{"id": 1, "name": "A"}, {"id": 2, "name": "B"}]
    return XMLResponse(content=data)


app = Lilya(
    routes=[Path("/items", items)]
)

Returns:

<root><id>1</id><name>A</name></root><root><id>2</id><name>B</name></root>

YAMLResponse

Response used to return data in YAML format.

from lilya.responses import YAMLResponse

!!! Warning Requires PyYAML To use this response, install it first pip install pyyaml

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import YAMLResponse


async def config():
    data = {"framework": "Lilya", "version": 1.0}
    return YAMLResponse(content=data)


app = Lilya(
    routes=[Path("/config", config)]
)

Response body:

framework: Lilya
version: 1.0

How it works

The YAMLResponse serializes Python objects into YAML format using yaml.safe_dump(). It's particularly useful for configuration APIs or developer-oriented endpoints.

Arguments

  • content — Any YAML-serializable Python object.
  • status_code — Optional HTTP status code. Defaults to 200.
  • headers — Optional custom headers.
  • media_type — Always "application/x-yaml".
  • charset — Defaults to "utf-8".

MessagePackResponse

Response used to return binary-encoded data using the MessagePack format.

from lilya.responses import MessagePackResponse

!!! Warning Requires msgpack To use this response, install it first pip install msgpack

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import MessagePackResponse


async def packed():
    data = {"ok": True, "value": 123}
    return MessagePackResponse(content=data)


app = Lilya(
    routes=[Path("/packed", packed)]
)

This will return a compact binary representation of the object. Clients can decode it using MessagePack in any language.

How it works

MessagePackResponse serializes data using msgpack.packb() with use_bin_type=True, making it ideal for high-performance APIs, IoT, or internal service communication.

Arguments

  • content — Any MessagePack-serializable Python object.
  • status_code — Optional HTTP status code. Defaults to 200.
  • headers — Optional custom headers.
  • media_type — Always "application/x-msgpack".
  • charset — Defaults to "utf-8".

NDJSONResponse

NDJSONResponse allows you to return newline-delimited JSON — a lightweight streaming-friendly format often used for logs, events, or real-time updates.

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import NDJSONResponse


async def read_data():
    data = [
        {"event": "start"},
        {"event": "progress"},
        {"event": "done"},
    ]
    return NDJSONResponse(data)

app = Lilya(
    routes=[Path("/read", read_data)]
)

This produces a response body like:

{"event": "start"}
{"event": "progress"}
{"event": "done"}

Each JSON object is written on a separate line, making it easy for clients to parse incrementally.

Media Type

application/x-ndjson

ImageResponse

ImageResponse is a convenient way to send raw image bytes directly to the client. It automatically sets the correct Content-Type and Content-Length headers based on the image data.

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import ImageResponse


async def image_download():

    # Load image bytes from a file
    with open("logo.png", "rb") as f:
        data = f.read()

    return ImageResponse(data, media_type="image/png")

app = Lilya(
    routes=[Path("/download", image_download)]
)

This sends the binary content of logo.png with appropriate headers:

Content-Type: image/png
Content-Length: <calculated automatically>

Supported Media Types

  • image/png
  • image/jpeg
  • image/webp
  • image/gif
  • image/svg+xml
  • or any other valid MIME type string.

You must specify the correct media_type when instantiating the response, or it will default to image/png.

Notes

  • Lilya does not perform image validation or conversion, it simply streams the bytes as-is.
  • The response is fully ASGI-compatible and can be sent using await response(scope, receive, send) in any Lilya or ASGI-based app.
  • Ideal for serving dynamically generated images, in-memory thumbnails, or image previews stored in memory.

EventStreamResponse

Response used to return Server-Sent Events (SSE). A simple, one-way streaming mechanism where the server continuously pushes updates to the client over a single HTTP connection.

from lilya.responses import EventStreamResponse

Example

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import EventStreamResponse
import asyncio


async def events():
    async def event_generator():
        for i in range(3):
            yield {"event": "tick", "data": {"count": i}}
            await asyncio.sleep(1)
        yield {"event": "done", "data": "Stream finished"}

    return EventStreamResponse(event_generator())


app = Lilya(
    routes=[Path("/events", events)]
)

This example continuously streams a sequence of events such as:

event: tick
data: {"count": 0}

event: tick
data: {"count": 1}

event: done
data: "Stream finished"

Each block is formatted as a Server-Sent Event, with event, data, id, and retry fields, separated by blank lines.

How it works

The EventStreamResponse accepts an asynchronous or synchronous generator that yields dictionaries representing events. Each dictionary can contain the following fields:

Field Description
event The event type name.
data The payload of the event. Supports str, dict, or list — complex types are JSON-encoded automatically.
id (Optional) Event ID for resuming streams.
retry (Optional) Reconnection time (in milliseconds) for the client.

Each event is formatted according to the SSE specification and sent as UTF-8 text chunks to the client.

Example with async generator

from lilya.apps import Lilya
from lilya.routing import Path
from lilya.responses import EventStreamResponse
import asyncio


async def stream():
    async def counter():
        for i in range(1, 6):
            yield {"event": "count", "data": {"value": i}}
            await asyncio.sleep(0.5)
        yield {"event": "complete", "data": "Counter finished"}

    return EventStreamResponse(counter(), retry=3000)


app = Lilya(
    routes=[Path("/stream", stream)]
)

This allows clients to receive events in real time without polling.

Example with EventSource in the browser

You can easily consume EventStreamResponse streams from JavaScript using the built-in EventSource API:

<script>
const source = new EventSource("/events");

source.addEventListener("tick", (event) => {
  const data = JSON.parse(event.data);
  console.log("Tick:", data.count);
});

source.addEventListener("done", () => {
  console.log("Stream finished");
  source.close();
});
</script>

Arguments

  • content — A synchronous or asynchronous generator yielding dicts representing events.
  • status_code — Optional HTTP status code. Defaults to 200.
  • headers — Optional custom headers.
  • media_type — Always "text/event-stream".
  • charset — Defaults to "utf-8".
  • retry — Optional global retry interval (in milliseconds) applied to all events unless overridden individually.

Notes

  • Automatically sets Content-Type: text/event-stream.
  • Both synchronous and asynchronous generators are supported.
  • Connections remain open until the generator completes or the client disconnects.
  • JSON-encodes dict or list payloads with standard spacing ({"key": value}).
  • Ideal for real-time dashboards, logs, notifications, and background job status updates.

Importing the appropriate class

This is the classic most used way of using the responses. The available responses contains a list of available responses of Lilya but you are also free to design your own and apply them.

Example

from lilya.apps import Lilya
from lilya.responses import JSONResponse
from lilya.routing import Path


def home():
    return JSONResponse({"message": "Welcome home"})


app = Lilya(routes=[Path("/", home)])

Build the Response

This is where the things get great. Lilya provides a make_response function that automatically will build the response for you.

from lilya.responses import make_response

Example

from lilya.apps import Lilya
from lilya.responses import make_response
from lilya.routing import Path


def home():
    return make_response({{"message": "Hello"}}, status_code=201)


app = Lilya(routes=[Path("/", home)])

By default, the make_response returns a JSONResponse but that can be also changed if the response_class parameter is set to something else.

So, why is this make_response different from the other responses? Well, here its where Lilya shines.

Lilya is pure Python, which means that it does not rely or depend on external libraries like Pydantic, msgspec, attrs or any other but allows you to build a custom encoder that can later be used to serialise your response automatically and then passed to the make_response.

Check the build a custom encoder and custom encoders with make_response for more details and how to leverage the power of Lilya.

Async content

You can pass coroutines as content to most standard responses. This will delay the evaluation of the content to the __call__ method if resolve_async_content() is not called earlier. The cool part, we reuse the main eventloop.

Note, this means we get the body attribute of the response as well as the content-length header later after the resolve_async_content() call (which is called in __call__).

Delegate to Lilya

Delegating to Lilya means that if no response is specified, Lilya will go through the internal encoders and will try to jsonify the response for you.

Let us see an example.

from lilya.apps import Lilya
from lilya.routing import Path


def home():
    return {"message": "Welcome home"}


app = Lilya(routes=[Path("/", home)])

As you can see, no response was specified but instead a python dict was returned. What Lilya internally does is to guess and understand the type of response parse the result into json and returning a JSONResponse automatically,

If the type of response is not json serialisable, then a ValueError is raised.

Let us see some more examples.

from lilya.apps import Lilya
from lilya.routing import Path


def home_dict():
    return {"message": "Welcome home"}


def home_frozen_set():
    return frozenset({"message": "Welcome home"})


def home_set():
    return set({"message": "Welcome home"})


def home_list():
    return ["Welcome", "home"]


def home_str():
    return "Welcome home"


def home_int():
    return 1


def home_float():
    return 2.0


app = Lilya(
    routes=[
        Path("/dict", home_dict),
        Path("/fronzenset", home_frozen_set),
        Path("/set", home_set),
        Path("/list", home_list),
        Path("/str", home_str),
        Path("/int", home_int),
        Path("/float", home_float),
    ]
)

And the list goes on and on. Lilya by design understands almost every single datastructure of Python by default, including Enum, deque, dataclasses, PurePath, generators and tuple.

This is archived by using Encoders. Mainly they are additional encoders for types tje json encoder cannot handle.

Pass body types directly to the application server

By default ASGI allows only byte strings as body type. Some servers are more lenient and allow also memoryview, bytearray or maybe something custom, which prevent in best case a copy. If you want to leverage this you can use passthrough_body_types. It is a type tuple on the response which contains the types which are directly forwarded to the application server. Please add bytes always to the types tuple, otherwise if you return bytes, it could be returned base64 encoded.

To pass them through you have three options:

  1. Subclassing the response and add passthrough_body_types either as ClassVar, assignment on initialization or as property.
  2. Some responses allow setting passthrough_body_types on the fly. Pass the wanted types tuple to it.
  3. Overwrite globally the types tuple in lilya.responses.Response. This is not recommended for libraries only for final applications.