A route connects a URL to a handler — a function that returns a value Chirp turns into a response. You register one with the@app.route()decorator.
Reach for explicit@app.route() when you want routing in code. For convention-based routing discovered from a pages/directory, see filesystem routing instead.
Route Registration
Register routes with the@app.route()decorator:
@app.route("/")
def index():
return "Hello, World!"
@app.route("/about")
def about():
return Template("about.html")
Routes are registered during the setup phase. At freeze time, the route table compiles into an immutable trie-based structure for fast matching.
HTTP Methods
By default, routes acceptGETrequests. Specify methods explicitly:
@app.route("/users", methods=["GET"])
def list_users():
return Template("users.html", users=get_all_users())
@app.route("/users", methods=["POST"])
async def create_user(request: Request):
data = await request.json()
user = create(data)
return Response(body=b"Created").with_status(201)
@app.route("/users/{id:int}", methods=["GET", "DELETE"])
async def user(request: Request, id: int):
if request.method == "DELETE":
delete_user(id)
return Response(body=b"Deleted")
return Template("user.html", user=get_user(id))
HEAD requests
EveryGET route also answers HEAD. Chirp selects the same handler and keeps
request.method == "HEAD", so middleware and handlers can inspect the real
method while returning the same representation metadata asGET. The HTTP
server sends the resulting status and headers, including theContent-Length
theGETbody would have, but sends zero body bytes.
Register an explicitHEADroute only when its metadata needs different
application logic. It takes precedence over the automaticGETfallback:
@app.route("/report", methods=["GET"])
def report():
return Page("report.html", "report_body")
@app.route("/report", methods=["HEAD"])
def report_metadata():
return Response("").with_header("X-Report-State", "building")
A route allowing GET advertises both GET and HEAD in a 405response
Allow header. Other methods remain exact matches. Built-in /healthand
/readyprobes follow the same metadata-without-body wire behavior, making
them safe for uptime monitors and deployment probes.
!!! note "Compatibility"
Before Chirp 0.4.0, aGET-only route rejected HEAD with 405 Method Not Allowed. Applications that relied on that rejection should register an
explicitHEADroute and return the response their policy requires.
Experimental HTTP QUERY routes
Chirp supports the RFC 10008QUERYmethod on explicitly registered routes.
Declare every request media range the resource understands:
@app.route(
"/search",
methods=["QUERY"],
query_media_types=("application/x-www-form-urlencoded",),
)
async def search(request: Request):
form = await request.form()
return Page("search.html", q=form.get("q", ""))
query_media_types is required for a QUERYroute and is rejected on routes
that do not includeQUERY. Chirp validates and normalizes the tuple when the
app freezes. Invalid values, duplicate ranges, unsupported wildcard shapes,
and empty declarations fail startup with the route name and repair guidance.
On the ASGI path, Chirp rejects a missing or malformedContent-Typewith
400, an undeclared media type with 415, an oversized body with 413when
the handler reads it, and a negotiated response that cannot satisfyAccept
with406. QUERY error responses advertise the declared formats through the
RFC 9651 Structured FieldAccept-Queryheader. Format parsers and application
validation remain responsible for distinguishing malformed content (400)
from a syntactically valid but unprocessable query (422). Chirp never sniffs
the body or replaces its declared media type.
This support is experimental and explicit-route-only. QUERY falls through the
fused sync path to ASGI even for a synchronous handler. It does not add a
filesystemquery() convention, native form transport, a TestClient.query()
shortcut, response caching, or a new return type. Use
TestClient.request("QUERY", ...)for tests; ordinary HTML needs a separately
designed GET fallback because native forms cannot submit QUERY.
QUERY uses the same typed HTML return pipeline as every other method. A
non-htmx request returningPagegets the full document; an htmx request to
the same route gets the selected named block.Fragment, OOB, Stream, and
Suspensekeep their existing rendering and fail-loud behavior, including a
hard failure rather than an empty swap when a required block is missing.
Withdebug=True, Chirp DevTools reports QUERY as a safe method and captures
its request and response content types, htmx target, selected block, render
intent, timing, streaming metadata, and errors. Programmatic clients can use
htmx.ajax("QUERY", path, context)while declarative QUERY transport remains a
separate compatibility gate.
CacheMiddlewarestill bypasses QUERY by default; an explicit query key
callback enables eligible response snapshots. Chirp's collision-safe key hashes the exact request body, media
metadata, target URI,Accept, configured vary headers, and htmx render shape
without exposing raw request content. Building the key retains the body for the
handler and uses the normal request-body limit. By itself, it does not enable cache reads or writes.
To opt in explicitly, manually registerCacheMiddlewarewith
query_key_func=query_cache_key. The default Noneand
AppConfig(cache_middleware_enabled=True)remain GET-only. Only eligible 200
buffered responses are stored; Cookie, Authorization,Set-Cookie, streaming,
and SSE paths bypass. Cached QUERY hits preserve response headers and render
intent and re-evaluate ETag/Last-Modified conditions.
See Experimental HTTP QUERY for GET-vs-QUERY guidance, deployment constraints, cache opt-in, compatibility evidence, and the remaining promotion gates.
Discovery, redirects, and validators
For a path with a declared QUERY route, a framework-generated405includes
QUERY and OPTIONS in Allow plus the structured Accept-Queryvalue.
Chirp also answersOPTIONS with a bodyless 204carrying those headers unless
you registered an explicitOPTIONSroute, which always wins.
Use existing response headers for retrievable results and equivalent resources:
return (
Response(rendered_results)
.with_header("Content-Location", "/results/r_54a59b9f")
.with_header("Location", "/searches/q_7f83b165")
.with_header("ETag", 'W/"search-v1"')
)
Identifiers are application-owned and must be opaque; never copy sensitive
QUERY content into a temporary URI. Ordinary GET and QUERYResponsevalues
shareIf-None-Match and If-Modified-Sinceevaluation when the application
supplies source-specificETag or Last-Modifiedheaders. Evaluation happens
after the full middleware chain, so validators may be attached in a handler or
middleware without duplicating RFC parsing. A matching stable representation
produces a bodyless304.
Nonce-protected HTML instead returns a fresh200and bypasses shared response
caching. This prevents a browser from reusing cached HTML containing nonce A
under a newly generated CSP containing nonce B. Stable JSON, Markdown, and
nonce-free HTML retain normal304behavior.
Redirect also remains the only redirect primitive. Chirp preserves 301,
302, 307, and 308so an RFC-compliant client can repeat QUERY, while
303hands the client off to GET. Test the actual client in your deployment;
when method retention is critical, prefer307 or 308because common client
compatibility around custom methods and301/302varies.
Range behavior is unchanged: range-capable responses such as the existing file sender retain GET semantics for QUERY, while generic HTML responses make no byte-range promise. Query formats should generally expose their own paging or limit controls instead.
Path Parameters
Dynamic segments are defined with curly braces:
@app.route("/users/{id}")
def user(id: str):
return f"User: {id}"
Type Conversions
Add a type suffix to auto-convert parameters:
@app.route("/users/{id:int}")
def user(id: int): # id is an int, not a str
return get_user(id)
@app.route("/price/{amount:float}")
def price(amount: float): # amount is a float
return f"${amount:.2f}"
Supported converters:
| Converter | Matches | Example |
|---|---|---|
str |
(default) any chars except/ |
/users/{name} |
int |
digits only | /users/{id:int} |
float |
digits with an optional decimal | /price/{amount:float} |
path |
any chars, including/ |
/files/{filepath:path} |
Parameter names must be valid Python identifiers, converters must be one of the supported names above, and routes use Chirp's{param} syntax rather than Flask-style <param>. Routes that differ only by parameter name, such as /users/{id} and /users/{name}, are duplicate route shapes and are rejected.
url_for() validates supplied path values against the same converter rules, so url_for("users.detail", id="alice") fails for /users/{id:int}instead of generating a URL the router cannot match.
Catch-All Routes
Use{name:path}to match the rest of the URL:
from pathlib import Path
@app.route("/files/{filepath:path}")
def serve_file(filepath: str): # filepath can contain slashes
return FileResponse(Path("uploads") / filepath)
FileResponse streams the file from disk with conditional-GET and Rangesupport — you don't read it into memory yourself.
Handler Signature Introspection
Chirp inspects your handler's signature to inject the right arguments:
# No arguments -- simplest case
@app.route("/")
def index():
return "Hello"
# Request only
@app.route("/search")
def search(request: Request):
q = request.query.get("q", "")
return Template("search.html", q=q)
# Path parameters only
@app.route("/users/{id:int}")
def user(id: int):
return get_user(id)
# Both
@app.route("/users/{id:int}/posts/{slug}")
def user_post(request: Request, id: int, slug: str):
return Template("post.html", post=get_post(id, slug))
# Extractable dataclasses — from query (GET), form (POST), or JSON body
@app.route("/search")
def search(form: SearchForm):
return Template("search.html", q=form.q, page=form.page)
# Dependency injection — register a type-keyed factory, then declare it as a param
def get_store() -> DocumentStore:
return DocumentStore()
app.provide(DocumentStore, get_store)
@app.route("/documents/{id}")
def document(id: str, store: DocumentStore):
return Template("doc.html", doc=store.get(id))
Argument resolution (first match wins):
- Request — Parameter named
requestor typed asRequest - Path parameters — From URL match, with type coercion
- Extractable dataclasses — Query string (GET), form body (POST), or JSON body. Dataclass fields are populated from request data.
- Service providers — Registered via
app.provide(annotation, factory). When a parameter's type matches a registered factory, Chirp injects the result.
Async Handlers
Handlers can be sync or async. Chirp handles both:
@app.route("/sync")
def sync_handler():
return "Sync"
@app.route("/async")
async def async_handler():
data = await fetch_data()
return Template("data.html", data=data)
Use async handlers when you need to awaitI/O (database queries, HTTP calls, file reads).
Error Handlers
Register error handlers by status code or exception type:
@app.error(404)
def not_found(request: Request):
return Template("errors/404.html", path=request.path)
@app.error(500)
def server_error(request: Request, error: Exception):
return Template("errors/500.html", error=str(error))
class PaymentRequired(HTTPError):
"""Raised by a handler when the caller has no active subscription."""
def __init__(self, detail: str = "Subscription required") -> None:
super().__init__(status=402, detail=detail)
@app.error(PaymentRequired)
def payment_required(request: Request, error: PaymentRequired):
return Template("errors/payment.html", reason=error.detail)
@app.error() takes a status code or an exception type. Chirp dispatches to the handler when a route raises a matching exception, or when it produces that status. The handler receives the Request and, for exception handlers, the raised exception. An HTTPError carries its own status, so the returned Templateis sent with that code. Error handlers use the same return-value system as route handlers.
Route Table
Every route you register lands in one table that Chirp compiles at freeze time.
Advanced: how contract checks validate route URLs in templates
Whenchirp check <app> validates templates, it extracts hx-get, hx-post, hx-put, hx-delete, hx-patch, action, and route-bearing macro arguments such as confirm_url, then verifies method + path against the route table.
Literal URLs are checked against route converter rules, so/users/alice does not satisfy /users/{id:int}. Dynamic URLs (built with Kida's ~ or {{ }}) are skipped; only literal URLs are validated. Use ~ or {{ var }}for path parameters — both work at render time and are correctly treated as dynamic by the checker.
confirm_url defaults to POST unless a companion confirm_methodis present, which lets dialog-style component APIs participate in the same route validation as raw htmx attributes.
Legacy component-styleaction="update-thing"values are no longer treated as route URLs. Chirp emits a warning instead of a false route error so you can migrate older macros to literal URLs or explicit htmx attributes over time.
The checker also validates selector-bearing htmx attributes (hx-target, hx-select, hx-include, and similar) for obvious syntax mistakes and unknown static #idtargets.