Add subdomain routing to FastAPI¶
FastAPI's maintainers have explicitly declined to add subdomain
routing inside the framework,
pointing users at nginx or Traefik instead. For multi-tenant SaaS
shapes (acme.api.example.com / globex.api.example.com) that
guidance forces every Python app to either run a reverse proxy just
for hostname routing, or roll its own.
The awkward way¶
The typical Python-only workaround — recommended in every
"multi-tenant FastAPI" blog post — is a Depends() that parses
request.url.hostname by hand:
from fastapi import FastAPI, Request, Depends, HTTPException
app = FastAPI()
def get_tenant(request: Request) -> str:
host = request.url.hostname or ""
parts = host.split(".")
if len(parts) < 3 or parts[-2:] != ["example", "com"]:
raise HTTPException(404)
return parts[0] # subdomain
@app.get("/billing")
async def billing(tenant: str = Depends(get_tenant)):
return load_billing(tenant)
Three problems:
- The hostname check is duplicated in every dependency that needs tenant context.
- The tenant isn't a typed route parameter — it doesn't appear in the function signature or OpenAPI spec.
- The
parts[-2:] != ["example", "com"]check is precisely the kind of hostname-suffix logic that's almost but not quite right (tryevil.example.com.attacker.com).
With URLPattern¶
Use URLPattern inside the dependency. yarlpattern accepts a yarl.URL
directly, and FastAPI's request.url is yarl-compatible (Starlette's
URL exposes it):
from fastapi import FastAPI, Request, Depends, HTTPException
from yarlpattern import URLPattern
app = FastAPI()
TENANT_HOST = URLPattern({"hostname": ":tenant.api.example.com"})
def get_tenant(request: Request) -> str:
result = TENANT_HOST.exec(str(request.url))
if result is None:
raise HTTPException(404)
return result.hostname["groups"]["tenant"]
@app.get("/billing")
async def billing(tenant: str = Depends(get_tenant)):
return load_billing(tenant)
For routes that have additional URL-shape constraints — say, a versioned API endpoint that only the admin tenant can hit — compose two patterns:
ADMIN_USERS = URLPattern({
"hostname": "admin.api.example.com",
"pathname": "/api/v:version(\\d+)/users/:id(\\d+)",
})
@app.get("/api/{ver}/users/{uid}")
async def admin_users(request: Request):
result = ADMIN_USERS.exec(str(request.url))
if result is None:
raise HTTPException(404)
g = result.pathname["groups"]
return load_user(int(g["id"]), version=int(g["version"]))
FastAPI's path router still dispatches on path. URLPattern adds the
hostname constraint and the type-enforced path-parameter capture
(:id(\\d+) rejects /api/v2/users/abc at the pattern level).
What you get for free¶
- Hostname routing FastAPI doesn't ship. No nginx, no Traefik, no framework patch. The pattern is the routing constraint.
- Wildcard subdomains that work correctly.
:tenant.api.example.comconsumes exactly one label and stops at the., soevil.example.com.attacker.comis not aneviltenant — it doesn't match at all. Hand-rolled hostname-suffix checks routinely get this wrong. - One source of truth for the URL contract. Pattern lives next to
the route. Adding tenant context to a new endpoint is one
Depends(get_tenant)away. - Type-enforced path params alongside hostname.
:id(\\d+)is in the pattern, not in a separate validator.