Migrating to v8

Responder 8.0 focuses on standards alignment and predictable defaults: redirects preserve the request method, text responses declare their charset the standard way, YAML uses its registered media type, url_for fails loudly, and API() no longer writes to your filesystem. It also introduces standalone, composable routers.

New in 8.0: Standalone Routers

responder.Router records route declarations without an API instance — like Flask blueprints or FastAPI’s APIRouter — and api.include_router(router, prefix=...) attaches them:

# users.py
from responder import Router

router = Router(prefix="/users", tags=["users"])

@router.get("")
def list_users(req, resp):
    resp.media = {"users": []}

# app.py
api = responder.API()
api.include_router(router, prefix="/v1")

Routers nest, carry group-level tags/dependencies/auth defaults, and scope their before_request hooks to the mounted prefix. See Composing Apps with Routers for the full guide. api.group() remains for quick same-file prefix grouping.

The rest of this guide covers the breaking changes.

No Implicit static/ Directory

Instantiating API() used to mkdir an empty static/ into the working directory (and failed on read-only filesystems). In v8, the default static/ is mounted only when the directory already exists:

# v7: this created ./static as a side effect
api = responder.API()

# v8: create the directory yourself if you want it served
Path("static").mkdir(exist_ok=True)
api = responder.API()

An explicitly passed static_dir that doesn’t exist now raises FileNotFoundError at construction instead of being silently created:

api = responder.API(static_dir="assets")  # FileNotFoundError if ./assets is missing

static_dir=None still disables static serving entirely. The same applies conceptually to templates_dir: neither is “created for you” anymore.

url_for Raises RouteNotFoundError

url_for used to return None for an unknown endpoint or route name, which rendered a literal "None" in templates. It now raises RouteNotFoundError, a LookupError subclass:

# v7: silently returned None
api.url_for("no-such-route")  # -> None

# v8: fails loudly
try:
    url = api.url_for("no-such-route")
except responder.RouteNotFoundError:
    url = "/fallback"

Since it subclasses LookupError, existing except LookupError blocks also catch it. Broken {{ url_for(...) }} calls in templates now surface as errors instead of dead /None links.

Generated URLs also percent-encode parameter values now:

api.url_for(get_file, name="a b")  # -> "/file/a%20b"

{param:path} segments keep their slashes. If you were pre-encoding values before passing them to url_for, stop — they would be double-encoded.

Redirects Default to 307

resp.redirect() (and the api.redirect() helper) used to default to 301 Moved Permanently, which browsers cache indefinitely and legacy clients rewrite from POST to GET. The default is now a method-preserving 307 Temporary Redirect:

# v7: 301 Moved Permanently
resp.redirect("/new-home")

# v8: 307 Temporary Redirect
resp.redirect("/new-home")

For a permanent, method-preserving redirect, pass permanent=True:

resp.redirect("/new-home", permanent=True)  # 308 Permanent Redirect

An explicit status_code= still takes precedence, so the old behavior is one argument away:

resp.redirect("/new-home", status_code=301)

If your tests assert on 301, update them to 307 (or pass an explicit status code in the handler).

Text Responses Declare a Charset

Text responses now declare their charset the standard way, as a Content-Type parameter, and the nonstandard Encoding response header is no longer sent:

resp.text = "hello"
# v7: Content-Type: text/plain          + Encoding: utf-8
# v8: Content-Type: text/plain; charset=utf-8

resp.encoding is now honored when encoding str bodies for all text types — previously resp.html ignored it, and str bodies fell through to a UTF-8 encode, producing mojibake for e.g. latin-1:

resp.encoding = "latin-1"
resp.html = "<p>café</p>"
# v8: body encoded as latin-1, Content-Type: text/html; charset=latin-1

The charset parameter is only added when the framework actually encodes a str body; raw bytes bodies (e.g. resp.file()) keep their bare media type. If a client was reading the old Encoding header, read the charset parameter of Content-Type instead. Tests that compare Content-Type exactly (== "text/html") need the charset parameter added.

YAML Is Served as application/yaml

YAML responses now use the RFC 9512 registered media type instead of the legacy application/x-yaml; the OpenAPI /schema.yml route follows suit:

# v7 response header: Content-Type: application/x-yaml
# v8 response header: Content-Type: application/yaml

Inbound requests are unaffected: bodies sent with either media type still parse as YAML, and Accept: application/x-yaml still negotiates a YAML response. Only clients (or tests) asserting on the response’s exact Content-Type need updating.

Empty YAML Bodies Return 400

An empty or whitespace-only YAML request body now raises 400 Bad Request from req.media(), matching the JSON format. Previously yaml.safe_load silently returned None and handlers had to guard against it:

@api.post("/config")
async def update(req, resp):
    data = await req.media(format="yaml")
    # v7: data could be None for an empty body
    # v8: an empty body never reaches here — the client gets a 400

An explicit YAML null document still parses to None; only the empty-body case is rejected.

Dependency Declarations and Floors

jinja2, pyyaml, and itsdangerous are now declared as direct dependencies. Responder imports them directly (templates, YAML content negotiation, and cookie-based session middleware) but previously received them only transitively via the starlette[full] extra — so most environments already have them installed, and a plain upgrade needs no action.

Several floors were also raised:

  • python-multipart>=0.0.12 — the first release shipping the python_multipart module name Responder imports.

  • apispec>=6.6 and marshmallow>=3.20 — what the OpenAPI extension is tested against.

  • setuptools>=77 (build-time only) — the first version supporting the PEP 639 SPDX license expression.

If your project pins one of these below its new floor, your resolver will report a conflict at upgrade time. Raise your pin to at least the floor — these are minimums Responder actually exercises, not preferences — or stay on Responder 7.x until you can.