This guide covers the optimized ASGI and WSGI modules for integrating Python web frameworks with erlang_python.
The py_asgi and py_wsgi modules provide high-performance request handling by using optimized C-level marshalling between Erlang and Python. These modules bypass the generic py:call() path with specialized NIFs that:
- Pre-intern keys - Python string keys are interned once at startup, eliminating per-request string allocation and hashing overhead
- Cache constants - Common values like HTTP methods, versions, and schemes are reused across requests
- Pool responses - Thread-local response pooling reduces memory allocation during request processing
- Direct NIF path - Specialized NIF functions avoid the overhead of generic Python call marshalling
Compared to generic py:call()-based handling:
| Optimization | ASGI | WSGI |
|---|---|---|
| Interned keys | +15-20% | +15-20% |
| Response pooling | +20-25% | N/A |
| Direct NIF | +25-30% | +25-30% |
| Total | ~60-80% | ~60-80% |
The ASGI module includes six additional NIF-level optimizations:
| Optimization | Improvement | Description |
|---|---|---|
| Direct Response Extraction | 5-10% | Extract (status, headers, body) directly to Erlang terms |
| Pre-Interned Headers | 3-5% | 16 common HTTP headers cached as PyBytes |
| Cached Status Codes | 1-2% | 14 common status codes cached as PyLong |
| Zero-Copy Body | 10-15% | Large bodies (≥1KB) use buffer protocol |
| Scope Template Caching | 15-20% | Thread-local cache of 64 scope templates |
| Lazy Header Conversion | 5-10% | Headers converted on-demand (≥4 headers) |
Total expected improvement: 40-60% for typical ASGI workloads on top of the base optimizations.
These optimizations are automatic and require no code changes.
%% Build ASGI scope from your HTTP server (e.g., Cowboy)
Scope = #{
type => <<"http">>,
http_version => <<"1.1">>,
method => <<"GET">>,
scheme => <<"http">>,
path => <<"/api/users">>,
query_string => <<"id=123">>,
headers => [[<<"host">>, <<"localhost:8080">>]],
server => {<<"localhost">>, 8080},
client => {<<"127.0.0.1">>, 54321}
},
%% Execute ASGI application
case py_asgi:run(<<"myapp">>, <<"application">>, Scope, Body) of
{ok, {Status, Headers, ResponseBody}} ->
%% Send response
cowboy_req:reply(Status, Headers, ResponseBody, Req);
{error, Reason} ->
%% Handle error
cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
end.-spec run(Module, Callable, Scope, Body) -> Result when
Module :: binary(),
Callable :: binary(),
Scope :: scope(),
Body :: binary(),
Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.Execute an ASGI application with default options.
Module- Python module containing the ASGI application (e.g.,<<"myapp">>)Callable- Name of the ASGI callable (typically<<"application">>or<<"app">>)Scope- ASGI scope map (see Scope Fields below)Body- Request body as binary
-spec run(Module, Callable, Scope, Body, Opts) -> Result when
Module :: binary(),
Callable :: binary(),
Scope :: scope(),
Body :: binary(),
Opts :: map(),
Result :: {ok, {integer(), [{binary(), binary()}], binary()}} | {error, term()}.Execute an ASGI application with options.
Options:
runner- Custom Python runner module (default:<<"hornbeam_asgi_runner">>)
-spec build_scope(Scope) -> {ok, reference()} | {error, term()}.
-spec build_scope(Scope, Opts) -> {ok, reference()} | {error, term()}.Build an optimized Python scope dict with interned keys. The returned reference can be passed to multiple ASGI calls for further optimization when handling many requests with similar scopes.
| Field | Type | Required | Description |
|---|---|---|---|
type |
binary | Yes | Request type (<<"http">> or <<"websocket">>) |
asgi |
map | No | ASGI version info (default: #{<<"version">> => <<"3.0">>}) |
http_version |
binary | No | HTTP version (<<"1.0">>, <<"1.1">>, <<"2">>) |
method |
binary | No | HTTP method (<<"GET">>, <<"POST">>, etc.) |
scheme |
binary | No | URL scheme (<<"http">> or <<"https">>) |
path |
binary | Yes | Request path |
raw_path |
binary | No | Raw path (defaults to path) |
query_string |
binary | No | Query string without leading ? |
root_path |
binary | No | Root path for mounted apps |
headers |
list | No | List of [Name, Value] header pairs |
server |
tuple | No | Server {Host, Port} tuple |
client |
tuple | No | Client {Host, Port} tuple |
state |
map | No | Request state dict |
extensions |
map | No | ASGI extensions |
%% Build WSGI environ from your HTTP server
Environ = #{
<<"REQUEST_METHOD">> => <<"GET">>,
<<"SCRIPT_NAME">> => <<>>,
<<"PATH_INFO">> => <<"/api/users">>,
<<"QUERY_STRING">> => <<"id=123">>,
<<"SERVER_NAME">> => <<"localhost">>,
<<"SERVER_PORT">> => <<"8080">>,
<<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
<<"wsgi.url_scheme">> => <<"http">>,
<<"wsgi.input">> => Body
},
%% Execute WSGI application
case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
{ok, {Status, Headers, ResponseBody}} ->
%% Parse status line (e.g., "200 OK")
StatusCode = parse_status(Status),
cowboy_req:reply(StatusCode, Headers, ResponseBody, Req);
{error, Reason} ->
cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req)
end.-spec run(Module, Callable, Environ) -> Result when
Module :: binary(),
Callable :: binary(),
Environ :: environ(),
Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.Execute a WSGI application with default options.
Module- Python module containing the WSGI applicationCallable- Name of the WSGI callableEnviron- WSGI environ map (see Environ Fields below)
Note: WSGI returns the status as a binary string (e.g., <<"200 OK">>), not an integer.
-spec run(Module, Callable, Environ, Opts) -> Result when
Module :: binary(),
Callable :: binary(),
Environ :: environ(),
Opts :: map(),
Result :: {ok, {binary(), [{binary(), binary()}], binary()}} | {error, term()}.Execute a WSGI application with options.
Options:
runner- Custom Python runner module (default:<<"hornbeam_wsgi_runner">>)
| Field | Type | Required | Description |
|---|---|---|---|
<<"REQUEST_METHOD">> |
binary | Yes | HTTP method |
<<"SCRIPT_NAME">> |
binary | Yes | Initial portion of URL path (can be empty) |
<<"PATH_INFO">> |
binary | Yes | Remainder of URL path |
<<"QUERY_STRING">> |
binary | No | Query string without leading ? |
<<"CONTENT_TYPE">> |
binary | No | Content-Type header value |
<<"CONTENT_LENGTH">> |
binary | No | Content-Length header value |
<<"SERVER_NAME">> |
binary | Yes | Server hostname |
<<"SERVER_PORT">> |
binary | Yes | Server port as string |
<<"SERVER_PROTOCOL">> |
binary | Yes | Protocol version (e.g., <<"HTTP/1.1">>) |
<<"wsgi.version">> |
tuple | No | WSGI version tuple (default: {1, 0}) |
<<"wsgi.url_scheme">> |
binary | Yes | URL scheme (<<"http">> or <<"https">>) |
<<"wsgi.input">> |
binary | Yes | Request body |
<<"wsgi.errors">> |
any | No | Error stream |
<<"wsgi.multithread">> |
boolean | No | Default: true |
<<"wsgi.multiprocess">> |
boolean | No | Default: true |
<<"wsgi.run_once">> |
boolean | No | Default: false |
<<"HTTP_*">> |
binary | No | HTTP headers with HTTP_ prefix |
Both ASGI and WSGI support custom runner modules for advanced use cases.
# custom_asgi_runner.py
async def run_asgi(app, scope, body):
"""
Custom ASGI runner.
Args:
app: The ASGI application callable
scope: ASGI scope dict
body: Request body bytes
Returns:
Tuple of (status_code, headers, body)
"""
# Custom pre-processing
scope['state']['custom_key'] = 'value'
# Call the ASGI app
response = await default_asgi_handler(app, scope, body)
# Custom post-processing
return response%% Use custom runner
py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body, #{
runner => <<"custom_asgi_runner">>
}).# custom_wsgi_runner.py
def run_wsgi(app, environ):
"""
Custom WSGI runner.
Args:
app: The WSGI application callable
environ: WSGI environ dict
Returns:
Tuple of (status, headers, body)
"""
# Custom pre-processing
environ['custom.key'] = 'value'
# Call the WSGI app
response = default_wsgi_handler(app, environ)
# Custom post-processing
return response%% Use custom runner
py_wsgi:run(<<"myapp">>, <<"app">>, Environ, #{
runner => <<"custom_wsgi_runner">>
}).Both py_asgi and py_wsgi fully support Python's sub-interpreter and free-threading modes:
Each request can run in an isolated sub-interpreter, providing:
- Isolated global state between requests
- No GIL contention between interpreters
- True parallelism for CPU-bound Python code
With Python 3.13's experimental free-threading build:
- No Global Interpreter Lock (GIL)
- True multi-threaded parallelism
- Best performance for concurrent requests
The modules automatically detect and use the optimal mode based on your Python installation.
-module(my_asgi_handler).
-behaviour(cowboy_handler).
-export([init/2]).
init(Req0, State) ->
Method = cowboy_req:method(Req0),
Path = cowboy_req:path(Req0),
QS = cowboy_req:qs(Req0),
Headers = cowboy_req:headers(Req0),
{ok, Body, Req1} = cowboy_req:read_body(Req0),
Scope = #{
type => <<"http">>,
method => Method,
path => Path,
query_string => QS,
headers => maps:to_list(Headers),
scheme => <<"http">>
},
case py_asgi:run(<<"myapp">>, <<"app">>, Scope, Body) of
{ok, {Status, RespHeaders, RespBody}} ->
Req = cowboy_req:reply(Status, maps:from_list(RespHeaders), RespBody, Req1),
{ok, Req, State};
{error, _Reason} ->
Req = cowboy_req:reply(500, #{}, <<"Internal Server Error">>, Req1),
{ok, Req, State}
end.-module(my_wsgi_handler).
-behaviour(elli_handler).
-export([handle/2, handle_event/3]).
handle(Req, _Args) ->
Environ = #{
<<"REQUEST_METHOD">> => elli_request:method(Req),
<<"PATH_INFO">> => elli_request:path(Req),
<<"QUERY_STRING">> => elli_request:query_str(Req),
<<"SERVER_NAME">> => <<"localhost">>,
<<"SERVER_PORT">> => <<"8080">>,
<<"SERVER_PROTOCOL">> => <<"HTTP/1.1">>,
<<"wsgi.url_scheme">> => <<"http">>,
<<"wsgi.input">> => elli_request:body(Req)
},
case py_wsgi:run(<<"myapp">>, <<"application">>, Environ) of
{ok, {Status, Headers, Body}} ->
StatusCode = parse_wsgi_status(Status),
{StatusCode, Headers, Body};
{error, _} ->
{500, [], <<"Internal Server Error">>}
end.
handle_event(_, _, _) -> ok.
parse_wsgi_status(Status) ->
[CodeBin | _] = binary:split(Status, <<" ">>),
binary_to_integer(CodeBin).For ASGI handlers that use await asyncio.sleep(), consider using erlang_asyncio.sleep() instead. This eliminates Python event loop overhead (~0.5-1ms per call) by using Erlang's native timer system:
# In your ASGI application
import erlang_asyncio
async def delay_handler(scope, receive, send):
# More efficient than asyncio.sleep()
await erlang_asyncio.sleep(0.001) # 1ms delay
await send({
'type': 'http.response.start',
'status': 200,
'headers': [[b'content-type', b'text/plain']],
})
await send({
'type': 'http.response.body',
'body': b'OK',
})For endpoints with short delays (1-10ms), this can improve throughput by 2-3x. See Asyncio for the full API.
- Getting Started - Basic usage guide
- Asyncio - Async event loop integration and erlang_asyncio module
- Threading - Thread support and callbacks
- Scalability - Performance tuning