SlowAPI
One handler. Two protocols.
Express ergonomics · FastAPI typing · NestJS structure — on WSGI and ASGI, at the same time.
Documentation · Quickstart · Guide · Why it exists · How it works
from slowfw import SlowAPI
app = SlowAPI()
@app.get("/users/{id:int}")
def get_user(id: int) -> dict:
return {"id": id}
@app.get("/fanout")
async def fanout() -> dict:
return {"results": await asyncio.gather(*(fetch(i) for i in range(10)))}
gunicorn app:app --workers 4 # WSGI — and the async handler still works
uvicorn app:app # ASGI — and the sync handler still works
Same file. No wsgi.py shim. No rewrite. No decision made in week one that you
have to live with in year two.
Why this exists
Every Python web framework makes you choose sync or async before you write a line of code, and that choice decides your deployment for years.
Pick FastAPI, then find a library whose only client is blocking. Pick Flask, then need server-sent events. Deploy on a platform that only speaks WSGI, and typed handlers with generated OpenAPI are simply unavailable to you.
That split is an artefact, not a necessity. Routing does not care about the protocol. Neither does validation, dependency injection, serialisation, or OpenAPI generation. Only two things care: how bytes arrive, and how bytes leave. In SlowAPI that is two files of about 150 lines, and nothing else in the codebase imports either of them.
So: write the handler that fits the work. Choose the server separately, later, and reversibly.
The full argument is in docs/growth.md.
What makes it different
It is not a compatibility shim. A fully synchronous request on the WSGI
path never creates an event loop at all — no task, no scheduler, no thread hop.
The dispatch pipeline is written once as async def so both protocols share
one implementation, and then stepped to completion by hand when nothing in the
chain can suspend:
def drive(coro):
try:
coro.send(None) # a coroutine that never awaits a future
except StopIteration as stop:
return stop.value # runs straight through, no loop required
SlowAPI works out, at route registration, whether a route's entire chain — handler, middleware, guards, interceptors, pipes, and every transitive dependency — is synchronous, and picks the cheapest correct strategy per route.
| Handler | Server | Strategy | Cost |
|---|---|---|---|
def |
WSGI | called directly, no loop exists | none |
async def |
ASGI | awaited on the server's loop | none |
def |
ASGI | offloaded to a worker thread | one thread hop |
async def |
WSGI | driven on a shared background loop | one thread hop |
The fourth row is the one most frameworks refuse. It works because SlowAPI keeps
one long-lived loop per process rather than calling asyncio.run per
request — so async connection pools and locks survive across requests on a
gunicorn worker.
The claim is asserted, not promised:
def test_a_fully_sync_wsgi_request_creates_no_event_loop():
shutdown_loop_thread()
TestClient(app, protocol="wsgi").get("/plain")
assert _LoopThread._instance is None
And the fast path is defended, not just achieved. A helper that is async def
but awaits nothing except call_next() cannot suspend, so @never_suspends
lets it keep the loop-free path instead of quietly costing it — which is what
the built-in interceptors and TimeoutMiddleware would otherwise do to every
synchronous route just by being installed.
Details: Dual-protocol dispatch.
Broken routes fail the build, not the deploy
Every handler signature, Depends chain, guard, interceptor, pipe and injected
provider is analysed before the process serves anything:
$ slowfw check main:app
FAIL 2 route(s) failed validation:
GET /reports/{id} (get_report): Could not resolve type hints for 'get_report':
name 'ReportService' is not defined.
POST /items (create_item): Parameter 'body' declares Body() inside Annotated[...]
and Query() as its default. Pick one.
Every broken route, not just the first. The same analysis runs during startup, so a typo that would have surfaced on the first production request instead stops the process from coming up — and in CI, stops the merge.
Three frameworks, one file
Two of the three things Python teams keep asking for live in JavaScript. SlowAPI brings them together, because they are complementary rather than competing.
Express gives it ergonomics
@app.get("/users/:id") # Express path syntax works
def get_user(req, res, id):
res.status(200).json({"id": id}) # chainable response
def timing(req, res, next): # (req, res, next) middleware
started = time.perf_counter()
next() # skip it and nothing downstream runs
res.set("X-Elapsed", f"{(time.perf_counter() - started) * 1000:.1f}ms")
app.use(timing)
FastAPI gives it types
@dataclass
class CreateUser:
email: str
age: int = 18
@app.post("/users", status_code=201)
def create(payload: CreateUser, notify: bool = Query(False)) -> User:
return service.create(payload)
Coerced from the wire, validated, every error reported at once, and documented
at /docs — from the same annotations that enforce it. Dataclasses need no
dependency; Pydantic works if you have it.
NestJS gives it structure
@controller("/users", tags=["users"])
@use_guards(RoleGuard)
class UserController:
def __init__(self, users: UserService): # constructor injection
self.users = users
@Get("/:id")
@roles("admin")
def show(self, id: int) -> User:
return self.users.find(id)
@module(
imports=[CoreModule],
controllers=[UserController],
providers=[UserService, RoleGuard],
exports=[UserService],
)
class UserModule: ...
app = SlowAPI(modules=[UserModule])
A real container with singleton/request/transient scopes, guards that run before anything is injected, interceptors that see the returned object, and pipes that transform one argument.
All three styles mix in one file. None of them is a legacy path.
Output shaping, because over-serialisation is the bug
@dataclass
class User:
id: int
email: str = field(metadata=expose(groups=("admin",)))
password_hash: str = field(default="", metadata=hidden())
created: datetime = field(default=None, metadata=expose(alias="createdAt"))
@Get("")
@public
def index(self) -> list[User]: ... # {"id":1,"createdAt":"..."}
@Get("/directory")
@roles("admin")
@serialize_with(groups=("admin",))
def directory(self) -> list[User]: ... # ...plus "email"
Same objects, different shapes, one decorator apart. password_hash never
leaves the process, and making it leak takes a deliberate act.
Install
pip install slowfw
Zero required runtime dependencies. No Pydantic, no Starlette, no anyio,
no click. Everything else is opt-in:
pip install "slowfw[asgi]" # uvicorn
pip install "slowfw[wsgi]" # gunicorn
pip install "slowfw[templates]" # jinja2
pip install "slowfw[pydantic]" # pydantic models as DTOs
pip install "slowfw[all]"
On the name.
slowapion PyPI is an unrelated rate-limiting library for Starlette. This project isslowfweverywhere — the distribution, the import, and the command — so the two never meet.
python -m slowfw new my-service # scaffold a deployable project
cd my-service && python -m slowfw run main:app --reload
Batteries, all of them optional
| Routing | Trie matching, {id:int} and :id syntaxes, six converters plus regex, reverse URLs, correct HEAD/405/Allow |
| Validation | Annotated or defaults; dataclasses, TypedDict, Pydantic; every error at once; constraints in the schema |
| Injection | Depends with caching and generator teardown, plus a scoped DI container |
| Structure | Controllers, modules, enforced exports, guards, interceptors, pipes |
| Middleware | CORS, security headers, trusted host, gzip, proxy headers, rate limit, sessions, request id, access logs, timeouts |
| Responses | JSON, HTML, redirects, streaming, SSE, files with ETag + byte ranges, automatic 304s |
| Operations | Background tasks, liveness/readiness probes, per-request deadlines |
| Templating | Autoescaping engine with inheritance, loops, filters — or Jinja2 |
| Static files | ETags, 304s, byte ranges, traversal and symlink protection, SPA fallback |
| OpenAPI | 3.1 generated from the running code, Swagger UI and ReDoc |
| Config | Typed settings from the environment, .env loader, production guardrails |
| Observability | Structured JSON logs, correlation ids threaded through logs and error bodies |
| Testing | TestClient over real WSGI and ASGI adapters, with uploads and a settable peer address |
| CLI | run, check, routes, openapi, secret, new |
Security posture
On by default: no tracebacks to clients, autoescaped templates, HttpOnly +
SameSite=Lax cookies, confined static paths, body and multipart limits,
X-Forwarded-* ignored unless the hop is trusted, HMAC-signed sessions with
constant-time comparison, and a correlation id on every response.
Opt-in with your values: CORS, trusted hosts, HSTS and CSP, proxy networks, rate limits.
Not provided, on purpose: authentication, an ORM, an admin. See docs/guide/security.md for the full line — including what remains yours.
Performance
make bench — in-process through the real adapters, so this includes building
the request rather than only dispatch. M-series laptop, Python 3.14, median of
20,000 iterations, best of three runs:
| Route | WSGI | ASGI |
|---|---|---|
plain text, def |
21µs | 110µs |
JSON, def |
28µs | 115µs |
typed params + validation, def |
38µs | 126µs |
Depends, def |
35µs | 127µs |
async def |
78µs | 75µs |
Read the diagonal. A synchronous handler is 3–5× cheaper on WSGI, because the fast path never touches an event loop. An async handler is cheapest on ASGI, because there is no thread hop back to a background loop.
The ASGI column for def handlers is dominated by asyncio.to_thread — the
honest cost of running blocking code without stalling the loop. Every ASGI
framework pays it. SlowAPI is the one that lets you stop paying it by changing
a deployment command rather than a codebase.
Against FastAPI
make bench-vs runs both frameworks on identical handlers through the raw ASGI
protocol, with no test client involved, and compares their responses byte for
byte before timing anything:
| Route | FastAPI (ASGI) | SlowAPI (ASGI) | SlowAPI (WSGI) |
|---|---|---|---|
| plain text | 154.1µs | 63.3µs | 17.8µs |
| JSON | 155.8µs | 68.7µs | 22.8µs |
| path + query validated | 175.5µs | 78.1µs | 32.8µs |
async def JSON |
15.6µs | 17.2µs | — |
Read that as one result rather than four: the gap is the thread hop, not the
framework. SlowAPI is 5–8× cheaper on synchronous routes because on WSGI it
never makes the hop, not because its routing or validation is cleverer. Where no
hop is involved, the two are level — FastAPI is ahead by about 8% on async def
handlers, roughly what SlowAPI spends minting the request ID FastAPI does not.
So the honest claim is narrow. Synchronous code deployed on WSGI is substantially cheaper here; asynchronous code is a wash, and you should be choosing on features and ecosystem, where FastAPI is far ahead.
Framework overhead is rarely your bottleneck. A handler that opens a database connection has already spent more than every figure above. The point of the tables is not the absolute numbers; it is that the right protocol depends on your code, and here that is a decision you can defer and revisit.
Documentation
| Quickstart | Ten minutes to a working typed service |
| Guide | Routing, requests, responses, validation, DI, modules, deployment |
| Dual-protocol dispatch | How one handler serves two protocols |
| Architecture | Module map and request lifecycle |
| Migrating | From Flask, FastAPI, Express, or NestJS |
| Growth plan | Why this exists and where it goes |
| FAQ | Including the honest "should I use this?" |
| Examples | Five runnable applications |
Project status
0.1.0. The API is stable enough to build on and young enough to change before 1.0.
- 214 tests, every dispatch scenario asserted on both protocols
- CI across Python 3.10–3.13 on Linux, macOS and Windows
- A CI job that installs with no extras and proves the zero-dependency claim
- Every configuration error raises at import, with a message naming the fix
Read what is deliberately absent before adopting it. A framework that tells you what it will not do is easier to plan around than one that implies it will do everything.
Contributing
Two rules cover most of it:
- Tests run on both protocols. Use the parameterised
clientfixture. - No new required runtime dependencies. Optional extras only.
See CONTRIBUTING.md and the invariants they come from.
git clone https://github.com/Somilg11/slowfw && cd slowfw
make install
make check # lint, types, and the full suite
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file slowfw-0.1.0.tar.gz.
File metadata
- Download URL: slowfw-0.1.0.tar.gz
- Upload date:
- Size: 226.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e5fc254aec42da0722d3e94e47ca5d2d29c9ff3559e5adea1bf2cae6c5b2787a
|
|
| MD5 |
4326f1071953d472903b638dfb6731f6
|
|
| BLAKE2b-256 |
57296a604d23029ca8f7b3fea3e02572a4091381af11d07e5531af4e4bc02dea
|
Provenance
The following attestation bundles were made for slowfw-0.1.0.tar.gz:
Publisher:
release.yml on Somilg11/slowfw
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
slowfw-0.1.0.tar.gz -
Subject digest:
e5fc254aec42da0722d3e94e47ca5d2d29c9ff3559e5adea1bf2cae6c5b2787a - Sigstore transparency entry: 2753257513
- Sigstore integration time:
-
Permalink:
Somilg11/slowfw@663b2ee9e6cb1108999cdcaf76277e741154b4ad -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Somilg11
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@663b2ee9e6cb1108999cdcaf76277e741154b4ad -
Trigger Event:
push
-
Statement type:
File details
Details for the file slowfw-0.1.0-py3-none-any.whl.
File metadata
- Download URL: slowfw-0.1.0-py3-none-any.whl
- Upload date:
- Size: 132.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b6de58c053c8f6a793ef6267476fd1b9bf690ac39fd7e7ed1e1dd937a6a8cc1
|
|
| MD5 |
628dc5edf0c58edc07215a6f50162300
|
|
| BLAKE2b-256 |
48307702d2b05045e5f27c4cc588b9f08ae808f80c358d8c061077f0744bced2
|
Provenance
The following attestation bundles were made for slowfw-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Somilg11/slowfw
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
slowfw-0.1.0-py3-none-any.whl -
Subject digest:
6b6de58c053c8f6a793ef6267476fd1b9bf690ac39fd7e7ed1e1dd937a6a8cc1 - Sigstore transparency entry: 2753257520
- Sigstore integration time:
-
Permalink:
Somilg11/slowfw@663b2ee9e6cb1108999cdcaf76277e741154b4ad -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Somilg11
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@663b2ee9e6cb1108999cdcaf76277e741154b4ad -
Trigger Event:
push
-
Statement type: