Skip to main content

Wred

A tiny Python/ASGI backend for HTML and JavaScript apps. One file. JSON from orjson, server from Granian. Pronounced "red".

Your API needs to return some JSON. It probably doesn't need a goddamn cathedral.

What you get

  • Async handlers, explicit HTTP methods, and fixed or parameterized routes.
  • Headers, query parameters, cached body reads, and JSON parsing.
  • JSON responses, custom response tuples, and a small-file helper.
  • Callable ASGI response support and routing relative to mounted paths.

What you don't get

Auth, sessions, databases, validation schemas, templates, CORS, CSRF protection, rate limiting, dependency injection, generated docs, background jobs, WebSockets, or built-in streaming.

Use existing ASGI middleware for CORS, authentication, sessions, or compression. Starlette's middleware can wrap Wred directly. Your app chooses its dependencies; Wred doesn't need to know they exist.

Install and run

Python 3.11+. For a new project:

uv init my-app
cd my-app
uv add wred

In an existing uv project, just uv add wred. Granian and orjson are included.

Save as app.py:

from wred import App

app = App()

@app.route('/')
async def hello(request):
    return {'hello': 'world'}

And smoke it:

uv run granian --interface asginl app:app

Open http://127.0.0.1:8000. Wred is HTTP-only; asginl disables lifespan. Unsupported protocols raise RuntimeError without sending HTTP messages, following ASGI guidance. You can also export a middleware wrapper as app.

Routes

Handlers are async and receive a request. Methods default to ['GET']; path parameters are strings passed by keyword.

@app.route('/users/{user_id}')
async def get_user(request, user_id):
    return {'id': user_id}

@app.route('/users', methods=['POST'])
async def create_user(request):
    return await request.json(), 'application/json', 201

@app.route('/users/{user_id}', methods=['PUT', 'PATCH'])
async def update_user(request, user_id):
    return {'id': user_id, 'method': request.method}

Different methods can have separate handlers on the same path. Fixed routes win over dynamic routes for that method; dynamic routes follow registration order. Registering the same path and method again replaces its handler. Unmatched paths or methods return JSON 404. No automatic HEAD, OPTIONS, or 405.

Mounting at /api makes a /users route handle /api/users. Routing respects root_path; request.path and the original scope keep the full incoming path.

Requests

API Returns
request.path Path without the query string.
request.method HTTP method.
request.headers Decoded header dict; use lowercase keys.
request.scope Original ASGI scope, including client, scheme, and middleware state.
request.query() Cached dict of string keys and values. No await.
await request.body() Cached raw bytes.
await request.json() Parsed JSON; an empty body becomes {}.
@app.route('/search')
async def search(request):
    return {'q': request.query().get('q', '')}

Query parameters preserve blank values; repeated keys keep the last value. JSON can be an object, array, or scalar. Validate inputs and convert types yourself.

Authentication middleware can populate the scope for handlers to check. Starlette's SessionMiddleware uses request.scope['session']; its cookies are signed, not encrypted. Wred doesn't supply a session property.

Responses

Dicts and lists become JSON with HTTP 200. Bytes default to application/octet-stream. Custom responses use (body, content_type, status[, headers]):

Handler return Response
{'hello': 'world'} JSON object, 200.
[{'id': 1}, {'id': 2}] JSON array, 200.
b'hello', 'text/plain', 200 Text response.
{'id': 3}, 'application/json', 201, [('Location', '/items/3')] Created resource with a custom header.
b'', 'text/plain', 303, [('Location', '/')] Redirect.
b'', 'application/json', 204 No content.

Dicts and lists always use application/json, including inside tuples. Other tuple bodies must be bytes. Headers are string pairs; repeated Set-Cookie headers are supported.

Return a callable ASGI response object to let it manage status, headers, and body through (scope, receive, send). Reuse external response classes for streaming, downloads, or SSE. Use ASGI middleware for behavior across routes.

Static files

from pathlib import Path

static_root = Path(__file__).parent / 'static'

@app.route('/')
async def index(request):
    return await app.static('index.html', root=static_root)

@app.route('/static/{filename}')
async def asset(request, filename):
    return await app.static(filename, root=static_root)

static(filename, root=...) returns (bytes, content_type, status). The root is required and accepts a string or Path. MIME types are guessed; missing, unreadable, or outside-root files return plain-text 404, including symlink escapes. Files are read in a worker thread, entirely into memory. Small files. Don't feed it a movie.

Errors

Malformed JSON, invalid UTF-8 query strings, and WredRequestError produce JSON 400 responses: {'error': message}. Raise WredRequestError from a handler for application validation failures. Unsupported response types produce JSON 500 errors; other exceptions propagate.

Example app

The notebook demo includes signup, login, refresh cookies, logout, protected note CRUD, and rate limiting. Those features belong to the app, not Wred.

git clone https://gitman.io/git/patx/wred
cd wred
uv sync
uv run granian --interface asginl --workers 1 example_app.app:app

Open http://127.0.0.1:8000. Data lives in memory and disappears on restart.

Benchmarks

GET / returning {"hello": "world"} on October 13, 2026. Three runs per framework, rotating their order each round.

Framework Median requests/sec Min Max
Wred 0.01 272,088 262,187 277,656
BlackSheep 2.6.4 211,619 205,719 220,455
MicroPie 0.32 207,720 197,520 216,010

Python 3.14.4, Granian 2.8.4, eight workers, HTTP/1 keep-alive, asyncio, no access logging. wrk -t4 -c256 -d20s --latency, five-second warmups. Client and server shared a machine over loopback. All nine measured runs had zero socket errors or non-success responses.

About 31% more requests/sec than MicroPie for this workload. It's a tiny JSON response, not your whole application. Benchmark your own shit.

Metadata

Release files for wred 0.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for wred 0.3
File Size Uploaded
wred-0.3.tar.gz 323.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wred 0.3
File Interpreter ABI Platform
wred-0.3-py3-none-any.whl Python 3 none any Details

Total release size: 330.8 kB

Release files / wred-0.3.tar.gz

Download URL wred-0.3.tar.gz
Size 323.1 kB
Tags Source
SHA-256 checksum
How to use checksums
ef3a382c8ff8200eb46ccd273c482261cab0d8487a6d7f1fb6b007cf024aa04a
BLAKE2b-256 checksum
How to use checksums
71c115f0cbfcc6b67b26d4b50a75cb6431d9ac5e1109c53aabc715c7943d38bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.32.5

Release files / wred-0.3-py3-none-any.whl

Download URL wred-0.3-py3-none-any.whl
Size 7.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4de3f7d00a4bc4830263e3a5fce98929f271a651d88402691bbd5190abea5ee8
BLAKE2b-256 checksum
How to use checksums
9d51246c2a9f5356d7751b5645635f330cda70547fa78d9a9b2f6b26d4e7a88f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via python-requests/2.32.5

Release history Release notifications | RSS feed

0.5

2 release files

0.4

2 release files

This release

0.3 This release

2 release files

0.2

2 release files

0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page