featherweb
A lightweight async web framework for Python with zero required dependencies — the
standard library and nothing else. Class-based controllers in the shape of Spring Boot,
input and output inferred from your type hints, and its own HTTP/1.1 and WebSocket server
built on asyncio.
from dataclasses import dataclass
from featherweb import App, Get, Post, Route
@dataclass
class Task:
id: int
title: str
done: bool = False
TASKS = [Task(1, "write a framework"), Task(2, "write its README", done=True)]
@Route("/tasks")
class TaskController:
@Get
async def index(self, done: bool | None = None) -> list[Task]:
return [task for task in TASKS if done is None or task.done is done]
@Get("/{task_id:int}")
async def show(self, task_id: int) -> Task:
return TASKS[task_id - 1]
@Post(status=201)
async def create(self, task: Task) -> Task:
TASKS.append(task)
return task
app = App(controllers=[TaskController])
if __name__ == "__main__":
app.run()
python app.py # http://127.0.0.1:8000/tasks
featherweb run app:app --port 9000 # or through the CLI
uvicorn app:app # or on any ASGI server
$ curl localhost:8000/tasks?done=true
[{"id": 2, "title": "write its README", "done": true}]
$ curl -X POST localhost:8000/tasks -d '{"id": 3, "title": "ship it"}'
{"id": 3, "title": "ship it", "done": false} # 201 Created
Nothing is declared twice. done: bool | None is a query parameter because that is the
only place a bool could come from; task_id is a path parameter because the route says
so; task: Task is the JSON body because a dataclass cannot be anything else. The return
annotation decides the response shape, and a field outside it is never sent.
Contents
- Install
- Why featherweb
- How it works
- Guide: controllers and routing · input · output · errors · middleware · lifespan · files and streaming · WebSocket · authentication · testing
- Running in production
- Compared with other frameworks
- What it is not
- Development
Install
pip install featherweb
# or
uv add featherweb
Python 3.12 or newer, on Linux, macOS and Windows. Installing it installs nothing else. There is one optional extra, and only for the JWT algorithms the standard library cannot do:
pip install "featherweb[crypto]" # adds RS256/RS384/RS512 and ES256/ES384/ES512
Why featherweb
Most Python web frameworks make you choose. Starlette is small and fast but hands you a raw request to pick apart yourself. FastAPI reads your type hints but brings Pydantic, Starlette and a dependency-injection system with it, and needs a separate server. Django and Flask give you structure, but they were designed for the synchronous world.
featherweb is an attempt to keep the good part of each and leave the weight behind:
- Types are the contract. You annotate a handler the way you would annotate any function, and that annotation is the whole specification: where each argument comes from, how it is converted, what gets rejected with a 422, and what the response looks like. There is no second schema to keep in sync.
- Controllers, not loose functions. Related routes live in one class under one prefix, with guards and error handlers that apply to the whole class. Anyone who has used Spring Boot or ASP.NET recognises the shape immediately.
- Nothing to install. Zero runtime dependencies means nothing to audit, nothing to pin and nothing that breaks on a transitive upgrade. It imports in about 20 ms, which matters for CLIs, serverless cold starts and test suites that import the app thousands of times.
- The server is included. An HTTP/1.1 and WebSocket server written for this framework, with request-smuggling defences and conservative limits. It is still plain ASGI 3 underneath, so uvicorn, hypercorn and granian work just as well.
- Errors at startup, not in production. A handler parameter the framework cannot provide, a duplicate route or a path converter that does not match its annotation is an exception when the app is built, not a 500 on the first request that hits it.
It is a good fit for JSON APIs, internal services, small sites and tools where you want structure and typed input without a stack of dependencies. See What it is not for where it does not fit.
How it works
featherweb's server or uvicorn / hypercorn / granian
│
socket ─► HTTP/1.1 parser ─► ASGI 3 (scope, receive, send)
│
▼
App ─► middlewares ─► auth ─► Router
│
controller method ◄────────┘
│
▼
serializer from the return type ─► response
The work splits into two moments, and as much as possible happens in the first:
When the app is built (App(controllers=[...]) or app.scan("package")), every
handler is inspected once. Its signature becomes a list of small extractor functions (one
per parameter, already knowing whether to read the path, the query, a header or the body,
and how to convert it), its return annotation becomes a serializer, and its route becomes
an entry in the router with the path converters compiled. Anything wrong is raised here.
When a request arrives, the server parses it into an ASGI scope and hands it to the
app. The middlewares wrap the call in order, the auth strategy attaches an Identity if
the route needs one, the router finds the controller method, and the precompiled
extractors run. Every validation failure is collected into a single 422. The handler's
return value then goes through the precompiled serializer. Nothing is parsed before it is
needed: the body, JSON, form data, cookies and query string are all read on first access.
A few rules follow from that design and are kept on purpose:
- ASGI 3 is the boundary, both ways. The app runs under any ASGI server, and the
bundled server runs any ASGI app, Starlette or Django included. The test suite runs the
same tests through
TestClient, featherweb's server and uvicorn. - No global magic. There is no implicit global
requestand no dependency-injection container. A handler receives exactly what it declares, and the only injectable objects are the framework's own:Request,WebSocket,IdentityandSession. - Pay for what you use. Multipart, WebSocket, static files and auth are separate modules that are only imported the first time something asks for them.
Guide
Controllers and routing
A controller is a plain class decorated with @Route. Each method decorated with a verb
becomes a route, and the paths are joined with the class prefix:
from uuid import UUID
from featherweb import App, Delete, Get, Put, Route
@Route("/users")
class UserController:
@Get # GET /users
async def index(self) -> list[str]: ...
@Get("/{user_id:uuid}") # GET /users/3f2a... (anything else is a 404)
async def show(self, user_id: UUID) -> dict[str, str]: ...
@Put("/{user_id:uuid}")
async def update(self, user_id: UUID, name: str) -> None: ... # None -> 204
@Delete("/{user_id:uuid}", status=202)
async def remove(self, user_id: UUID) -> None: ...
@Route # no prefix
class HealthController:
@Get("/health")
async def health(self) -> str:
return "ok"
app = App(controllers=[UserController, HealthController])
- Verbs:
Get,Post,Put,Patch,Delete,Options,Head, andWsfor WebSocket. AGETroute answersHEADfor free. - Path converters:
{name},{name:int},{name:float},{name:uuid}and{name:path}, which swallows the rest of the path. A value that does not match the converter means the route does not match, so the answer is a 404, not a 422. /usersand/users/are the same route.- Controllers are instantiated once, without arguments, when the app is built.
In a larger project, mark controllers where they live and let the app find them:
from featherweb import App
app = App()
app.scan("myproject.controllers") # imports the package recursively and registers them
Input, inferred
A handler receives exactly what it declares. For each parameter the first rule that fits wins:
- its name appears in the route path → path parameter;
- it is
Request,WebSocket,IdentityorSession→ the object itself; - it is a dataclass,
TypedDictor pydantic-style model → the JSON body; - it is
UploadFileorlist[UploadFile]→ an uploaded file; - it is a simple type (
str,int,float,bool,Enum,Literal,UUID,datetime,date,time,Decimal,X | None,list[X]) → the query string; Annotated[T, marker]overrides all of the above.
Anything that fits none of them is an error when the route is registered, not a surprise in production. A parameter with a default is optional, and one without it is required.
from typing import Annotated
from uuid import UUID
from featherweb import Cookie, Get, Header, Query, Request, Route
@Route("/search")
class SearchController:
@Get("/{tenant:uuid}")
async def search(
self,
tenant: UUID, # named in the route -> path
request: Request, # framework types, as themselves
q: str = "", # simple type -> query string
tags: list[str] | None = None, # repeatable query parameter
per_page: Annotated[int, Query("perPage")] = 25, # a different name on the wire
agent: Annotated[str, Header("user-agent")] = "",
session: Annotated[str | None, Cookie()] = None,
) -> dict[str, object]: ...
The markers are Query, Header, Cookie, Form, File and Body. Body() takes the
whole JSON body as any type, and Body("field") picks one field out of it:
from typing import Annotated
from featherweb import Body, Form, Post, Route
@Route("/auth")
class LoginController:
@Post("/token")
async def token(self, username: Annotated[str, Body("username")]) -> dict[str, str]: ...
@Post("/form") # an HTML form, url-encoded or multipart
async def form(self, email: Annotated[str, Form()]) -> str: ...
A value that will not convert is a 422 naming the field and the reason, with every problem in the request reported at once rather than one per round trip:
{"detail": [{"location": "query", "field": "perPage", "message": "expected an integer"}]}
Output, from the return type
The return annotation decides the response:
| Annotation | Response |
|---|---|
-> None |
204 No Content |
-> str |
text/plain |
-> bytes |
application/octet-stream |
-> Response[T], FileResponse, StreamingResponse, RedirectResponse |
as built |
| anything else | JSON shaped by the annotation |
JSON output is built from the declared type, not from the object you return. Fields are read by attribute, so an ORM row serializes as well as a dataclass does. Whatever the object carries beyond the declared type is left out, which is how a password column stops being an incident:
from dataclasses import dataclass
from featherweb import Get, Route
@dataclass
class PublicUser:
id: int
name: str
@Route("/users")
class UserController:
@Get("/{user_id:int}")
async def show(self, user_id: int) -> PublicUser:
return await database.fetch_user(user_id) # has .password_hash too; it is not sent
For a body plus a status, headers or cookies, return Response[T]. The body is still
shaped by T:
from featherweb import Post, Response
@Post(status=201)
async def create(self, task: Task) -> Response[Task]:
response = Response(task, headers={"location": f"/tasks/{task.id}"})
response.set_cookie("last_created", str(task.id), httponly=True)
return response
Errors
Raise HTTPError anywhere for an HTTP answer, or subclass it for errors of your own:
from featherweb import HTTPError
class TaskNotFound(HTTPError):
status = 404
detail = "no such task"
raise HTTPError(409, "that title is taken") # {"detail": "that title is taken"}
To turn any exception into a response, write a handler. On a controller it applies to
that controller only. On a @ControllerAdvice class it applies to the whole app. The most
specific handler wins, local before global:
from featherweb import ControllerAdvice, ExceptionHandler, Response
@ControllerAdvice
class Errors:
@ExceptionHandler(LookupError)
async def not_found(self, exc: LookupError) -> Response[dict[str, str]]:
return Response({"detail": str(exc)}, status=404)
@ExceptionHandler(PermissionError)
async def forbidden(self, exc: PermissionError) -> Response[dict[str, str]]:
return Response({"detail": "not allowed"}, status=403)
An unhandled exception is logged and answered with a 500 whose body says only
{"detail": "internal server error"}. App(debug=True) puts the traceback there instead,
which is useful in development and should stay off in production.
Middleware
A middleware is a class with async __call__(request, call_next). The lowest order is
the outermost layer, and because exceptions become responses inside the chain, a
middleware sees 404s and 500s too:
from featherweb import App, CORS, GZip, Middleware, Next, Request, Response
@Middleware(order=10)
class Timing:
async def __call__(self, request: Request, call_next: Next) -> Response:
response = await call_next(request)
response.headers["x-served-by"] = "featherweb"
return response
app = App(
controllers=[TaskController],
middlewares=[Timing, GZip(minimum_size=500), CORS(allow_origins=["https://example.com"])],
)
CORS and GZip are included. A class is instantiated for you, and an instance is used
as it is, which is how a configured middleware gets in.
Lifespan
Open connections and pools when the server starts, and close them when it stops. Hooks can be sync or async:
from featherweb import App
app = App(controllers=[TaskController])
pool = DatabasePool(DATABASE_URL)
@app.on_startup
async def connect() -> None:
await pool.open()
@app.on_shutdown
async def disconnect() -> None:
await pool.close()
Files, uploads and streaming
from collections.abc import AsyncIterator
from featherweb import FileResponse, Get, Post, Request, Route, StreamingResponse, UploadFile
from featherweb.staticfiles import StaticFiles
@Route("/files")
class FileController:
@Get("/report")
async def report(self, request: Request) -> FileResponse:
# ETag, Last-Modified, If-None-Match -> 304, and Range -> 206, all handled.
return FileResponse("report.pdf", request=request, filename="report.pdf")
@Get("/events")
async def events(self) -> StreamingResponse:
async def ticks() -> AsyncIterator[str]:
for index in range(10):
yield f"tick {index}\n"
return StreamingResponse(ticks(), media_type="text/plain")
@Post("/upload")
async def upload(self, document: UploadFile) -> dict[str, object]:
# Parsed off the socket as it arrives and spooled to disk past 1 MB,
# so a 100 MB upload costs a buffer and a temporary file, not 100 MB.
return {"name": document.filename, "size": document.size}
app.mount("/static", StaticFiles("assets", max_age=3600))
StaticFiles refuses path traversal twice. The path segments are checked before anything
touches the disk, and the resolved path must still be inside the directory afterwards,
which also catches a symlink pointing out of it.
WebSocket
from featherweb import Route, WebSocket, Ws
@Route("/ws")
class SocketController:
@Ws("/echo")
async def echo(self, ws: WebSocket) -> None:
await ws.accept()
async for message in ws.iter_text():
await ws.send_text(f"echo: {message}")
Framing, masking, fragmentation and ping/pong happen below you, in whichever server is
running, featherweb's own or uvicorn's. The handler is the same either way. There are
also receive_json/send_json and iter_bytes/iter_json, and a disconnect ends the
loop cleanly.
Authentication
Choose a strategy, pass it to App, and guard controllers with @Authenticated and
@Roles. Sessions live in a signed cookie, so there is no server-side store:
from dataclasses import dataclass
from featherweb import (
App,
Authenticated,
Get,
HTTPError,
Identity,
Post,
Roles,
Route,
Session,
SessionAuth,
)
auth = SessionAuth(secret=SECRET) # a list of secrets rotates keys without logouts
@dataclass
class Credentials:
username: str
password: str
@Route("/account")
class AccountController:
@Post("/login")
async def login(self, credentials: Credentials, session: Session) -> dict[str, str]:
user = await users.check_password(credentials.username, credentials.password)
if user is None:
raise HTTPError(401, "wrong username or password")
auth.login(session, user.name, roles=user.roles)
return {"hello": user.name}
@Post("/logout")
async def logout(self, session: Session) -> None:
auth.logout(session)
@Route("/admin")
@Authenticated # applies to every method below
class AdminController:
@Get("/me")
async def me(self, identity: Identity) -> dict[str, object]:
return {"id": identity.id, "roles": sorted(identity.roles)}
@Get("/reports")
@Roles("admin") # the class's rule *and* this one
async def reports(self) -> list[str]:
return ["everything"]
app = App(controllers=[AccountController, AdminController], auth=auth)
No identity is 401, and an identity without the role is 403. For an API, swap in bearer tokens. The guarded handlers do not change:
from featherweb import JWTAuth
auth = JWTAuth(SECRET, algorithms=["HS256"])
token = auth.issue("ada", roles=["admin"], expires_in=3600) # Authorization: Bearer <token>
The JWT layer follows RFC 8725. You name the algorithms, and a token never gets to choose
how it is verified. alg: none is refused whatever you ask for, exp/nbf/iat are
checked, and nothing in the payload is read before the signature checks out. Signatures
are compared with hmac.compare_digest, and session cookies default to HttpOnly,
Secure and SameSite=Lax.
Testing
TestClient calls the application directly, with no socket and no server, and runs the
lifespan hooks:
from featherweb.testing import TestClient
async def test_tasks_are_listed():
async with TestClient(app) as client:
response = await client.get("/tasks", params={"done": True})
assert response.status == 200
assert response.json()[0]["title"] == "write its README"
async def test_a_bad_id_is_a_422():
async with TestClient(app) as client:
response = await client.post("/tasks", json={"id": "nope", "title": "x"})
assert response.status == 422
Running in production
The bundled server is meant for production, not only development:
featherweb run app:app --host 0.0.0.0 --port 8000
featherweb run app:app --workers 4 # one process per core, same port
featherweb run app:app --ssl-certfile cert.pem --ssl-keyfile key.pem
It enforces conservative limits by default: an 8 KB request line, 64 KB of headers across
at most 100 fields, a 1 MB body (App(max_body_size=...)) and a 10 s header timeout
against slowloris. It rejects the request-smuggling vectors: Content-Length together
with Transfer-Encoding, conflicting Content-Length values, obs-fold and invalid header
names. Put it behind a reverse proxy (nginx, Caddy, a cloud load balancer) as you would any
Python server.
--workers shares the port with SO_REUSEPORT. Windows does not have it, so there it
falls back to a single process and says so. Being an ASGI 3 application, featherweb also
runs anywhere else:
uvicorn app:app --workers 4
hypercorn app:app
granian --interface asgi app:app
And the bundled server runs any ASGI application, not only featherweb's:
from featherweb.server.runner import run
run(some_starlette_app, port=8000)
Compared with other frameworks
Design
| featherweb | FastAPI | Starlette | Litestar | Flask | Falcon | |
|---|---|---|---|---|---|---|
| Runtime dependencies | 0 | 10 | 4 | 26 | 7 | 0 |
| Interface | ASGI | ASGI | ASGI | ASGI | WSGI | ASGI + WSGI |
| Input from type hints | yes | yes | no | yes | no | no |
| Response shaped by the return type | yes | yes | no | yes | no | no |
| Class-based controllers | yes | no | endpoints | yes | views | resources |
| Dependency injection | no, by design | yes | no | yes | no | no |
| Automatic OpenAPI | not yet | yes | no | yes | no | no |
| Production server included | yes | no | no | no | no | no |
| Auth (sessions, JWT, roles) included | yes | helpers | middleware | yes | sessions | no |
In short:
- Choose FastAPI or Litestar when you want OpenAPI documentation generated for you, dependency injection, Pydantic models and a large ecosystem of plugins and answers online.
- Choose Starlette when you want a minimal toolkit and prefer to parse requests yourself.
- Choose Django when you want an ORM, an admin and migrations out of the box.
- Choose featherweb when you want typed input and output with controllers, an included server and auth, and no dependency tree to maintain.
Numbers
Measured with python benchmarks/compare.py --all on one Windows desktop, CPython 3.12.
They show the order of magnitude, not a ranking. Run the script on your own hardware
before relying on them.
Import cost is everything import <framework> loads beyond what a bare interpreter
already has. You pay it on every process start: CLI tools, serverless cold starts and test
runs.
| Framework | Import time | Modules loaded | Dependencies |
|---|---|---|---|
| featherweb | 21 ms | 29 | 0 |
| Starlette | 168 ms | 178 | 4 |
| Falcon | 204 ms | 229 | 0 |
| Flask | 271 ms | 264 | 7 |
| BlackSheep | 332 ms | 303 | 15 |
| Litestar | 435 ms | 430 | 26 |
| Quart | 449 ms | 437 | 20 |
| FastAPI | 468 ms | 318 | 10 |
Throughput is requests per second with every framework behind the same uvicorn, so the
difference is the framework and not the server. The load generator is written in Python and
runs on the same machine, which puts a ceiling on all of them. Absolute numbers on a Linux
server with wrk will be much higher, and differences of about 10% are within the noise
here.
| Stack | /plaintext |
/json |
/user/7 |
|---|---|---|---|
| featherweb + uvicorn | 8,358 | 7,339 | 7,034 |
| Starlette + uvicorn | 7,812 | 6,786 | 7,007 |
| featherweb, own server | 7,193 | 6,454 | 6,291 |
| FastAPI + uvicorn | 5,218 | 5,091 | 4,430 |
featherweb runs in the same range as Starlette, the thinnest layer of the group, even though it validates input and shapes output from the types the way FastAPI does.
What it is not
No ORM, no dependency injection container, no database integration, no OAuth, no HTTP/2 or HTTP/3, and no automatic OpenAPI yet. The type hints that would generate OpenAPI are in place, but generating it is not. The others are deliberate omissions, not a roadmap gap. Use the database library you like. featherweb does not need to know about it.
Development
Requires uv.
uv sync # create .venv (Python 3.12) with the dev dependencies
uv run pytest # across TestClient, the bundled server and uvicorn
uv run ruff check # lint
uv run ruff format # formatting
uv run pyright # type checking, strict
python benchmarks/measure.py --all # the targets in PLAN.md section 2
uv sync --group bench && python benchmarks/compare.py --all # against other frameworks
python examples/demo/app.py # a page that pokes every feature above
The API reference is docs/api.md. The roadmap, the architecture and the per-component security requirements are in PLAN.md, which is written in Portuguese, and RELEASING.md covers cutting a version.
Status
Version 1.0: everything above works and is tested, and the public API follows semantic versioning from here, so a breaking change means a new major version. The two areas carrying the most risk, the HTTP/1.1 parser and the authentication layer, are built to the requirements in PLAN.md section 6, with the request-smuggling and token-forgery cases covered by tests.
License
MIT — see LICENSE.
Release files for featherweb 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| featherweb-1.0.0.tar.gz | 264.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| featherweb-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 367.6 kB
Release files / featherweb-1.0.0.tar.gz
| Download URL | featherweb-1.0.0.tar.gz |
|---|---|
| Size | 264.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4d3f09236a63b3c030f603147d7d9affec85c700d54253d39ee3999b60284977
|
|
BLAKE2b-256 checksum How to use checksums |
4870677357ce74b1327b531f58f1f5fd4071b0e94f7d2f6a145fda00861f7b0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / featherweb-1.0.0-py3-none-any.whl
| Download URL | featherweb-1.0.0-py3-none-any.whl |
|---|---|
| Size | 103.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
11b341cd6e2d42614755c4e75d1917c8b0fffa1ffe914a8a5d1ad4e8cf393da6
|
|
BLAKE2b-256 checksum How to use checksums |
e5a5f1f1f32a7659e01e413915e5cf2ae0285be667506610aa560b07262f687b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|