Skip to content

Sending Responses

Return the value and sillo encodes it. What each type becomes, and the builders in sillo.responses for when you need a status, a header, a redirect, a file or a stream.

Return the value. That is the whole default.

from sillo import HttpContext
@app.get("/users")
async def users(ctx: HttpContext):
return [{"id": 1, "name": "Ada"}]

That is a complete handler. sillo encodes the return value and sends it with a 200. There is no builder to import, no response object to construct, and nothing to configure before you have a body.

Reach for a builder — json(...), redirect(...), file(...) — when you need something the return value cannot carry: a different status, a header, a cookie, a stream. Those are the second half of this page.

The rule has two branches, and it is worth knowing which one you are on, because the second surprises people.

Anything the encoder reduces to a string is sent as text/plain. Everything else is sent as application/json.

You returnContent-TypeBody
{"ok": True}application/json{"ok":true}
[1, 2, 3]application/json[1,2,3]
(1, 2), {1, 2}application/json[1,2]
42, 1.5, Trueapplication/json42, 1.5, true
Noneapplication/jsonnull
Decimal("1.50")application/json1.5
a Pydantic modelapplication/jsonits fields
a dataclassapplication/jsonits fields
"hello"text/plainhello
datetime(...), date(...)text/plain2026-01-01T12:00:00
UUID(...)text/plainthe hyphenated form
an Enum membertext/plainits value
b"raw"text/plainraw

Every one of those is a 200.

The same asymmetry catches dates. A datetime on its own is text/plain, because the encoder turns it into a string and a bare string is text. The same datetime inside a dict is JSON, because the dict is what gets encoded:

return datetime(2026, 1, 1) # text/plain: 2026-01-01T00:00:00
return {"at": datetime(2026, 1, 1)} # application/json: {"at":"2026-01-01T00:00:00"}

A returned value carries a body and nothing else. These need a response object:

You needUse
a status other than 200json(data, status_code=201), or created(...)
a response headerjson(data, headers={"X-Total": "5"})
a cookiejson(data).set_cookie("session", token)
HTML, XML, or raw byteshtml(...), xml(...), raw(...)
a redirectredirect(...) and its named variants
a file, a stream, or SSEfile(...), stream(...), sse(...)
an empty bodyempty() — a 204

There is no ctx.status_code and no ctx.set_cookie: the context describes the request. Anything about the response lives on the response.

A response_model applies to a returned value exactly as it does to a built one, and it is the reason returning a dict stays safe as a codebase grows — undeclared fields are dropped rather than leaked:

class User(BaseModel):
id: int
name: str
@app.get("/me", response_model=User)
async def me(ctx: HttpContext):
return {"id": 1, "name": "Ada", "password_hash": "..."}
# sends {"id":1,"name":"Ada"}

A handler that returns a built response is not filtered — building the response says the status, headers and body are your business. See Response Models.

Every builder lives in sillo.responses and is re-exported from the root package, so these two imports are the same function:

from sillo.responses import json
from sillo import json

Import the module — from sillo import responses, then responses.json(...) — in a file that also needs the standard library’s json or html, or the file builtin.

Each one returns a response object. Nothing is sent until you return it.

json(data)application/json, using the framework encoder — what a bare return data gives you, plus somewhere to hang options
text(body)text/plain
html(body)text/html
xml(body)application/xml, or pass content_type= for a narrower one
raw(body, content_type=...)bytes under whatever type you name
empty()a 204 with no body
from sillo import HttpContext, text, html, xml, raw
@app.get("/status")
async def status(ctx: HttpContext):
return text("Service is running.")
@app.get("/welcome")
async def welcome(ctx: HttpContext):
return html("<h1>Welcome</h1>")
@app.get("/feed")
async def feed(ctx: HttpContext):
return xml("<feed/>", content_type="application/atom+xml")
@app.get("/thumb")
async def thumb(ctx: HttpContext):
return raw(png_bytes, content_type="image/png")

json takes the usual encoder knobs — indent to pretty-print, ensure_ascii, and custom_encoder for a type it does not know:

from sillo import json
return json(payload, indent=2)

The three status codes worth their own name, because getting them wrong changes what clients do:

from sillo import created, accepted, no_content
created({"id": 7}, location="/items/7") # 201, with the Location header
accepted({"job": "j-1"}) # 202: taken, not finished
no_content() # 204: nothing to parse

created and accepted both work with no body at all — created(location=...) is a valid 201.

from sillo import redirect, permanent_redirect, see_other, temporary_redirect
redirect("/new-path") # 302
permanent_redirect("/new-path") # 301 — moved for good
see_other("/thanks") # 303 — post/redirect/get
temporary_redirect("/new-path") # 307 — method and body preserved

permanent_redirect is a 301 by default, which lets a client turn the repeated request into a GET. Pass preserve_method=True for a 308, which repeats the method and body as-is.

To redirect to a named route, ask the context for the URL:

from sillo import HttpContext, redirect
@app.get("/user/{user_id}", name="user_profile")
async def get_user(ctx: HttpContext, user_id: int):
return {"user_id": user_id}
@app.get("/users")
async def list_users(ctx: HttpContext):
return redirect(ctx.url_for("user_profile", user_id=42))
from sillo import file, download
file("path/to/report.pdf") # rendered inline if the browser can
download("path/to/report.pdf") # always saved, never rendered
download("exports/2026-01.csv", "january.csv") # under a different name

download is file with the attachment disposition already set, which is the one you want for anything user-supplied — it stops an uploaded HTML file executing against your origin.

from sillo import HttpContext, stream
@app.get("/numbers")
async def numbers(ctx: HttpContext):
async def source():
for i in range(10):
yield f"{i}\n"
return stream(source())

ndjson takes an async iterable of objects and writes one JSON document per line, encoding each with the framework encoder — so a datetime in the stream does not raise:

from sillo import HttpContext, ndjson
@app.get("/export")
async def export(ctx: HttpContext):
async def rows():
async for row in db.stream("select * from events"):
yield row
return ndjson(rows())

sse writes the same iterable as Server-Sent Events, with a keepalive comment every 15 seconds by default so an idle connection is not dropped by a proxy:

from sillo import HttpContext, sse
@app.get("/events")
async def events(ctx: HttpContext):
async def source():
async for event in bus.subscribe():
yield event
return sse(source(), retry=3000)

Pass keepalive=None to switch the ping off, and encoder= to control how each item becomes a data: line.

paginate and apaginate build a paged JSON response. They take the context first, because they read the current page off its query string and build the next and previous links from its URL:

from sillo import HttpContext, paginate
@app.get("/products")
async def products(ctx: HttpContext):
return paginate(ctx, all_products, strategy="page_number")

Strategies are page_number, limit_offset and cursor. Use apaginate when the data handler is async. See Pagination for the full reference.

Two builders raise instead of returning, so the framework’s error handling formats the result — the same envelope, the configured 404 page, and the centralised logging every other error goes through:

from sillo import HttpContext, abort, not_found
@app.get("/admin")
async def admin(ctx: HttpContext):
if not ctx.user.is_admin:
abort(403, detail="Admins only")
return {"ok": True}
@app.get("/items/{item_id:int}")
async def get_item(ctx: HttpContext, item_id: int):
item = await db.get(item_id)
if item is None:
not_found(detail=f"Item {item_id} not found")
return item

abort raises HTTPException; not_found raises NotFoundException. Both accept headers=, which is how a 401 carries its WWW-Authenticate:

from sillo import abort
abort(401, detail="Invalid credentials", headers={"WWW-Authenticate": "Bearer"})

Every builder returns a response object, and the setters are methods on it. Each returns the response, so they chain:

from sillo import HttpContext, json
@app.get("/api/data")
async def get_data(ctx: HttpContext):
return (
json({"data": "success"})
.status(200)
.set_cookie("session", "abc123")
.set_header("X-API-Version", "1.0")
)

The body comes first. That is not a rule to remember so much as the shape of the thing — there is no response to set a header on until a builder has made one.

.status(code)override the status
.set_header(key, value) / .set_headers(dict)add or replace headers
.remove_header(key) / .remove_headers(keys)drop them again
.set_cookie(key, value, **opts) / .set_cookies(list)set cookies
.set_permanent_cookie(key, value)a cookie with a ten-year expiry
.delete_cookie(key)expire one
.cache(max_age=3600, private=True) / .no_cache()Cache-Control
.add_csp_header(policy)a Content-Security-Policy
.set_body(body)replace the body
.has_header(key) / .content_lengthread what is there
from sillo import HttpContext, json
@app.post("/login")
async def login(ctx: HttpContext):
return json({"message": "Logged in"}).set_cookie(
key="user_token",
value="secret-token",
httponly=True,
max_age=3600,
)

Most builders also take status_code= and headers= directly, which is shorter when that is all you need:

from sillo import json
return json({"error": "not allowed"}, status_code=403)

The classes behind the builders are exported from the same module, for a handler that wants to construct one directly or subclass it:

from sillo import HttpContext
from sillo.responses import (
BaseResponse,
JSONResponse,
HTMLResponse,
PlainTextResponse,
RedirectResponse,
FileResponse,
StreamingResponse,
)
@app.get("/users-json")
async def get_users_json(ctx: HttpContext):
return JSONResponse([{"id": 1, "name": "Alice"}], status_code=200)

Subclass BaseResponse when you need a format the framework does not ship — the pattern is to encode in __init__ and hand the parent the finished bytes and a content type:

from sillo.responses import BaseResponse
from dicttoxml import dicttoxml
from sillo import HttpContext
class XMLResponse(BaseResponse):
def __init__(self, content, *args, **kwargs):
super().__init__(
body=dicttoxml(content),
content_type="application/xml",
*args,
**kwargs,
)
@app.get("/data.xml")
async def get_xml_data(ctx: HttpContext):
return XMLResponse({"user": {"name": "John Doe", "id": "123"}})

For one-off XML the xml builder is enough; a class earns its place when the encoding is shared across handlers, or when you want the type name to appear in your own code.

The code is the part of a response that clients branch on, and a handful of distinctions carry most of the weight.

201 versus 200. A created resource is a 201 with a Location header pointing at it. Returning 200 loses the client’s ability to follow the new resource without constructing the URL themselves.

202 versus 200. Work that has been accepted but not finished is a 202. Returning 200 tells the client the work is done, and they will believe you.

204 versus 200 with an empty body. A 204 says “no content, do not parse”. Some clients will attempt to parse an empty 200 body and throw.

404 versus 403. A 404 for a resource that exists but is not yours hides its existence, which is sometimes what you want; a 403 confirms it exists. Pick deliberately. The choice is an information-disclosure decision, not a cosmetic one.

409 versus 422. A conflict with current state is a 409; an unacceptable value is a 422. Returning 422 for “email already taken” tells a client to fix a correctly-formatted email.

500 versus 503. A 500 is your bug and retrying will not help. A 503 is temporary and retryable, and pairs with Retry-After. Clients back off on 503 and give up on 500, which is exactly the behaviour you want from each.

Cache-Control decides whether anything between you and the client keeps a copy. Its absence is not “no caching”, intermediaries apply heuristics. Say what you mean, especially no-store on anything authenticated.

Content-Disposition decides whether a browser renders or downloads. attachment on user-supplied files prevents an uploaded HTML file executing with your origin’s cookies.

X-Content-Type-Options: nosniff stops browsers second-guessing your content type, which is what turns a mislabeled upload into script execution.

Location on 201 and on redirects. Relative is fine and avoids leaking your internal hostname.