Intermediate
You deploy a FastAPI app to production with Uvicorn, load tests it, and watch the request latency climb past 200ms under moderate traffic. You add workers, tune the event loop, and it still struggles. The bottleneck is not your Python code — it is the server itself. Granian is a Rust-written HTTP server for Python ASGI, WSGI, and RSGI applications that outperforms Uvicorn and Gunicorn on throughput benchmarks, often by 20-40%, without requiring you to change a single line of your application code.
Granian is a drop-in replacement for Uvicorn and Gunicorn in most deployments. You install it with pip install granian, point it at your existing app object, and it handles the rest. It supports HTTP/1.1 and HTTP/2 out of the box, configures workers and threads from the command line, and works with every major Python web framework — FastAPI, Starlette, Flask, Django, and more.
In this article we will cover what Granian is and why it is faster than alternatives, how to serve both ASGI and WSGI apps, how to configure workers, threads, and HTTP/2, how to set up TLS for HTTPS in development, and how to build a production-ready configuration. By the end you will have a complete working setup you can deploy today.
Serving a FastAPI App: Quick Example
If you have a FastAPI app ready, you can switch to Granian in under 60 seconds. Here is a minimal working example from installation to running server:
# install.sh
pip install granian fastapi
Then create a simple FastAPI application:
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"message": "Hello from Granian"}
@app.get("/health")
def health():
return {"status": "ok"}
Start the server with Granian from the terminal:
# terminal
granian --interface asgi main:app --host 0.0.0.0 --port 8000
Output:
[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8000
[INFO] Spawning worker-1 with pid: 12345
[INFO] ASGI app loaded from module: main
The --interface asgi flag tells Granian to use the ASGI protocol, which is what FastAPI and Starlette expect. The main:app argument follows the same module:attribute pattern Uvicorn uses, so you can swap servers without modifying your code. The next sections go deeper into configuration, WSGI apps, and production tuning.
What Is Granian and Why Is It Faster?
Granian is an open-source HTTP server written in Rust, created by Giovanni Barillari. While Uvicorn and Gunicorn are written in Python and rely on C extensions for performance, Granian’s core networking layer is pure Rust — which means it handles I/O, connection management, and request parsing at native speed with no GIL contention at the transport layer.
The performance difference becomes meaningful under high concurrency. In a Python web server, every request that hits the Python interpreter is subject to the GIL. Granian’s architecture minimizes the time Python code spends waiting on the transport layer, giving your application more headroom to do actual work. In independent benchmarks on JSON responses and database-bound routes, Granian consistently delivers 20-40% higher requests-per-second than Uvicorn with equivalent worker counts.
| Feature | Granian | Uvicorn | Gunicorn + uvicorn workers |
|---|---|---|---|
| Language | Rust (core) | Python | Python |
| ASGI support | Yes | Yes | Yes (via worker class) |
| WSGI support | Yes | No | Yes |
| RSGI support | Yes (native) | No | No |
| HTTP/2 | Built-in | No | No |
| Multi-worker | Yes (–workers) | Via Gunicorn | Yes |
| Threading model | Configurable | Single-threaded | Single-threaded per worker |
| Zero-downtime reload | Planned | Via Gunicorn | Yes |
Granian supports three interface modes: asgi for modern async frameworks, wsgi for traditional synchronous frameworks, and rsgi — Granian’s own async interface that is even faster than ASGI because it avoids some protocol overhead. Most teams will use asgi or wsgi since those match existing frameworks.
Serving ASGI Apps (FastAPI, Starlette)
ASGI is the interface used by FastAPI, Starlette, Django Channels, and Litestar. To serve an ASGI app, pass --interface asgi to Granian. Here is a more realistic FastAPI application with a route that simulates I/O work:
# app_asgi.py
import asyncio
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="Granian ASGI Demo")
class Item(BaseModel):
name: str
price: float
items_db = {}
@app.post("/items/")
async def create_item(item: Item):
items_db[item.name] = item
# Simulate async I/O (database write, etc.)
await asyncio.sleep(0.001)
return {"created": item.name, "price": item.price}
@app.get("/items/{name}")
async def get_item(name: str):
if name not in items_db:
return {"error": "Not found"}, 404
return items_db[name]
@app.get("/items/")
async def list_items():
return list(items_db.values())
Serve it with Granian using two workers to take advantage of multiple CPU cores:
# terminal
granian --interface asgi app_asgi:app --host 0.0.0.0 --port 8000 --workers 2
Output:
[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8000
[INFO] Spawning worker-1 with pid: 23001
[INFO] Spawning worker-2 with pid: 23002
[INFO] ASGI app loaded from module: app_asgi
Each worker is a separate OS process with its own Rust event loop. Under load, the two workers handle requests in parallel without GIL contention between them. You can test the endpoint from a second terminal with curl:
# terminal (second window)
curl -X POST http://localhost:8000/items/ \
-H "Content-Type: application/json" \
-d '{"name": "keyboard", "price": 129.99}'
Output:
{"created":"keyboard","price":129.99}
Serving WSGI Apps (Flask, Django)
If your application uses Flask, Django, or any other WSGI framework, Granian handles those too with --interface wsgi. This is a significant advantage over Uvicorn, which is ASGI-only and cannot serve WSGI apps at all. Here is a Flask app that serves a small product catalog:
# app_wsgi.py
from flask import Flask, jsonify, request
app = Flask(__name__)
catalog = {
"python-book": {"title": "Learning Python", "price": 49.99},
"rust-book": {"title": "Programming Rust", "price": 54.99},
}
@app.route("/products/")
def list_products():
return jsonify(list(catalog.values()))
@app.route("/products/")
def get_product(slug):
product = catalog.get(slug)
if not product:
return jsonify({"error": "Not found"}), 404
return jsonify(product)
@app.route("/products/", methods=["POST"])
def add_product():
data = request.get_json()
if not data or "slug" not in data:
return jsonify({"error": "slug required"}), 400
catalog[data["slug"]] = {"title": data.get("title", ""), "price": data.get("price", 0)}
return jsonify({"added": data["slug"]}), 201
Start it with Granian in WSGI mode:
# terminal
granian --interface wsgi app_wsgi:app --host 0.0.0.0 --port 8001 --workers 4
Output:
[INFO] Starting granian
[INFO] Listening at: http://0.0.0.0:8001
[INFO] Spawning worker-1 with pid: 24001
[INFO] Spawning worker-2 with pid: 24002
[INFO] Spawning worker-3 with pid: 24003
[INFO] Spawning worker-4 with pid: 24004
[INFO] WSGI app loaded from module: app_wsgi
WSGI mode is synchronous — Granian handles concurrency by running multiple workers rather than using async I/O within a single process. For CPU-bound or synchronous-database Flask apps, this is the correct model. You get the speed of Rust I/O with the familiar Flask programming model.
Configuration Options: Workers, Threads, and HTTP/2
Granian’s full configuration is available both as CLI flags and as a Python API. The CLI approach is the most common and works well with Docker and systemd. Here are the key options you will reach for in production:
# terminal -- production-style command
granian \
--interface asgi \
--host 0.0.0.0 \
--port 8000 \
--workers 4 \
--threads 2 \
--http auto \
--log-level info \
main:app
What each flag does:
| Flag | Default | When to change it |
|---|---|---|
--workers N | 1 | Set to number of CPU cores for CPU-bound workloads |
--threads N | 1 | Increase for I/O-heavy apps on modern Rust async runtime |
--http auto | 1 | Use auto to enable HTTP/2 when TLS is active |
--log-level | info | Set to warning in high-traffic production to reduce log overhead |
--backlog N | 1024 | Increase for high-concurrency scenarios |
--interface | required | Always required: asgi, wsgi, or rsgi |
You can also configure Granian entirely from Python code, which is useful when you want to embed server startup in a script or control it programmatically:
# serve_programmatic.py
from granian import Granian
server = Granian(
target="main:app",
address="0.0.0.0",
port=8000,
interface="asgi",
workers=4,
threads=2,
log_level="info",
)
server.serve()
Running this script with python serve_programmatic.py starts the server identically to the CLI approach. The Python API is especially useful if you are building a deployment tool or a management script that starts and monitors Granian as a subprocess.
Adding TLS for HTTPS in Development
Granian supports TLS (HTTPS) natively, and enabling it in development is straightforward. You need a certificate and key file — you can generate self-signed ones with openssl for local testing:
# terminal -- generate self-signed cert for local dev
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost"
Output:
Generating a RSA private key
.............++++
writing new private key to 'key.pem'
-----
Then pass the certificate and key to Granian:
# terminal -- serve with TLS
granian --interface asgi main:app \
--host 0.0.0.0 \
--port 8443 \
--http auto \
--ssl-certificate cert.pem \
--ssl-keyfile key.pem
Output:
[INFO] Starting granian
[INFO] Listening at: https://0.0.0.0:8443
[INFO] HTTP/2 enabled (TLS active)
[INFO] Spawning worker-1 with pid: 25001
With --http auto and TLS active, Granian automatically enables HTTP/2, which allows multiple requests to be multiplexed over a single connection. This is a meaningful throughput gain for browsers and API clients that make several concurrent requests to the same server. In production you would typically terminate TLS at a reverse proxy like Nginx and connect Granian over plain HTTP on an internal port — but having native TLS available makes local HTTPS development and container-to-container encrypted traffic trivially easy to set up.
Real-Life Example: Production-Ready Granian Setup
Let us put everything together into a realistic scenario: a FastAPI service behind Granian with structured logging, environment-based configuration, and a health check endpoint — the kind of setup you would actually push to a container. This uses python-dotenv to load config from a .env file and uvicorn‘s logging format for compatibility with existing log aggregation pipelines.
# service.py
import os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
import time
app = FastAPI(
title="Order Service",
version="1.0.0",
)
# In-memory store (replace with a real DB in production)
orders: dict = {}
_start_time = time.time()
class Order(BaseModel):
customer: str
product: str
quantity: int
price_each: float
class OrderResponse(BaseModel):
order_id: str
customer: str
product: str
total: float
@app.get("/health")
def health():
uptime_seconds = round(time.time() - _start_time, 1)
return {"status": "ok", "uptime_seconds": uptime_seconds, "orders": len(orders)}
@app.post("/orders/", response_model=OrderResponse)
async def create_order(order: Order):
order_id = f"ORD-{len(orders) + 1:04d}"
total = round(order.quantity * order.price_each, 2)
orders[order_id] = {"customer": order.customer, "product": order.product, "total": total}
return OrderResponse(order_id=order_id, customer=order.customer,
product=order.product, total=total)
@app.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order(order_id: str):
order = orders.get(order_id)
if not order:
raise HTTPException(status_code=404, detail=f"Order {order_id} not found")
return OrderResponse(order_id=order_id, **order)
The startup script reads configuration from environment variables so the same code works in development and production:
# run.py
import os
from granian import Granian
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
WORKERS = int(os.getenv("WORKERS", "2"))
LOG_LVL = os.getenv("LOG_LEVEL", "info")
server = Granian(
target="service:app",
address=HOST,
port=PORT,
interface="asgi",
workers=WORKERS,
log_level=LOG_LVL,
)
if __name__ == "__main__":
print(f"[startup] Granian on {HOST}:{PORT} with {WORKERS} workers")
server.serve()
Start it and test the health and order endpoints:
# terminal
WORKERS=4 python run.py
# second terminal -- test endpoints
curl http://localhost:8000/health
curl -X POST http://localhost:8000/orders/ \
-H "Content-Type: application/json" \
-d '{"customer":"Alice","product":"mechanical keyboard","quantity":1,"price_each":129.99}'
curl http://localhost:8000/orders/ORD-0001
Output:
{"status":"ok","uptime_seconds":3.1,"orders":0}
{"order_id":"ORD-0001","customer":"Alice","product":"mechanical keyboard","total":129.99}
{"order_id":"ORD-0001","customer":"Alice","product":"mechanical keyboard","total":129.99}
This pattern — environment-driven config, structured startup script, health endpoint — gives you a container-ready service. A Dockerfile would set the WORKERS, PORT, and LOG_LEVEL environment variables and run python run.py as the entrypoint. Swap the in-memory orders dict for a real database connection and you have a production service.
Frequently Asked Questions
Is Granian a full replacement for Uvicorn?
For most ASGI applications, yes. Granian supports the same module:app startup convention, the same --workers flag, and the same ASGI interface that FastAPI and Starlette expect. The main gaps compared to Uvicorn are the absence of a stable zero-downtime reload feature and slightly less ecosystem documentation. If your deployment relies on Gunicorn managing Uvicorn worker processes, you will need to test Granian’s multi-worker mode as a direct replacement — in most cases it works identically.
Can I use Granian with a full Django project?
Yes. Django’s WSGI entrypoint is at yourproject.wsgi:application by default. Run granian --interface wsgi yourproject.wsgi:application and Granian serves it. If you are using Django Channels or Django with an async setup, use --interface asgi and point it at yourproject.asgi:application. Granian handles both interfaces in the same binary, which makes it easier to manage than running separate Gunicorn and Uvicorn processes for different projects.
When should I increase workers vs threads?
Workers are separate OS processes — use more workers to scale CPU-bound work and to run on multiple CPU cores. Each worker has its own Python interpreter and GIL, so two workers can run Python code truly in parallel. Threads in Granian refer to async worker threads within the Rust runtime and are useful for I/O-heavy apps that benefit from more concurrency per process. A good starting point is --workers N_CPU --threads 1 and benchmark from there.
Does HTTP/2 require a reverse proxy?
No — Granian can terminate HTTP/2 connections directly with --http auto and TLS certificates. In production, you may still want Nginx or a load balancer in front of Granian for TLS certificate management (Let’s Encrypt renewal, for example) and to handle static file serving. But if you are running a containerized service in Kubernetes or behind a cloud load balancer that handles TLS termination, you can run Granian in plain HTTP/2 cleartext mode on an internal port.
Does Granian support hot reload for development?
Granian does not have a built-in file watcher for hot reload in the same way Uvicorn’s --reload flag works. For development you can use watchfiles — run watchfiles "granian --interface asgi main:app" src/ to restart Granian when files in your src/ directory change. This gives you an equivalent development experience. Hot reload in production is on Granian’s roadmap but not yet stable as of mid-2026.
How do I run Granian in Docker?
Use a standard Python base image, install your dependencies, and set CMD to the Granian CLI command. A minimal Dockerfile looks like: FROM python:3.12-slim, RUN pip install granian fastapi, COPY . ., CMD ["granian", "--interface", "asgi", "main:app", "--host", "0.0.0.0", "--port", "8000"]. Set --workers via an environment variable rather than hardcoding it in the image so the same image works on machines with different CPU counts.
Conclusion
Granian is a practical, production-ready upgrade for any Python web application that currently uses Uvicorn or Gunicorn. The Rust-powered transport layer delivers measurably higher throughput without requiring changes to your application code — you replace the server command and your app keeps running. We covered how to serve ASGI apps like FastAPI and Starlette, how to serve WSGI apps like Flask and Django, how to configure workers and threads for your CPU topology, how to enable TLS and HTTP/2, and how to build an environment-driven startup script ready for containerization.
The real-life example showed how a production service looks with health checks, structured configuration, and the programmatic Python API. You can extend it by adding database connection pooling, Prometheus metrics, or structured JSON logging — all of which integrate cleanly with a Granian-served FastAPI app. Consider running a quick wrk or locust benchmark against your current Uvicorn setup and a Granian equivalent to see the throughput difference on your specific workload.
For full documentation on all CLI flags and advanced features, see the official Granian repository on GitHub. The project’s benchmarks page also has current performance comparisons against Uvicorn, Hypercorn, and other Python ASGI servers.