Introduction to Sanic
Sanic is a modern, asynchronous Python web framework built on top of asyncio. It was created with one primary goal in mind: speed. Inspired by Flask's developer-friendly API but designed for high-performance production workloads, Sanic allows you to write HTTP servers that can handle thousands of requests per second on a single process. Unlike traditional WSGI frameworks such as Flask and Django, Sanic uses the ASGI-compatible event loop to handle requests concurrently without blocking.
At its core, Sanic leverages Python's asyncio library and the uvloop event loop (a fast drop-in replacement for the default asyncio loop) to achieve performance that rivals Node.js and Go in many benchmarks. If you have ever wanted Flask's simplicity with the throughput of a non-blocking server, Sanic is the framework for you.
Why Sanic Matters
Performance is the headline feature, but Sanic offers several other compelling advantages:
- Asynchronous by default: Every request handler can be defined as a coroutine, making it trivial to call other async libraries such as
aiohttp,asyncpg, ormotor. - Flask-like ergonomics: Routing, blueprints, request parsing, and JSON responses all feel familiar to Flask developers.
- Production-ready out of the box: Sanic ships with its own HTTP server, so you do not need Gunicorn or uWSGI to deploy it.
- Streaming support: Both request and response streaming are first-class citizens, which is ideal for large payloads or real-time data.
- Extensible: Middleware, listeners, and a clean plugin ecosystem let you hook into any part of the request lifecycle.
Getting Started
Installation
Sanic requires Python 3.8 or newer. Install it using pip in a virtual environment:
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
pip install sanic
If you want the optional performance boost from uvloop and httptools, install them as well. Sanic will automatically use them if available:
pip install sanic[uvloop] sanic[httptools]
Your First Sanic Application
Create a file named app.py and add the following code:
from sanic import Sanic
from sanic.response import json
app = Sanic("MyFirstApp")
@app.get("/")
async def hello(request):
return json({"message": "Hello, Sanic!"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
Run the application with python app.py and open http://localhost:8000 in your browser or use curl:
curl http://localhost:8000/
# {"message":"Hello, Sanic!"}
Notice that the handler is defined with async def. This is the key difference from Flask: every handler runs inside the event loop, so you can await database calls, HTTP requests, or any other asynchronous operation without blocking the server.
Routing and Path Parameters
Sanic supports dynamic route parameters using the same syntax as Flask. You can capture values from the URL and even enforce types with string, int, float, and path converters.
from sanic import Sanic
from sanic.response import json
app = Sanic("RoutingDemo")
@app.get("/users/<user_id:int>")
async def get_user(request, user_id):
return json({"user_id": user_id, "type": type(user_id).__name__})
@app.get("/files/<path:path>")
async def get_file(request, path):
return json({"requested_path": path})
@app.route("/posts/<slug>", methods=["GET", "POST"])
async def handle_post(request, slug):
if request.method == "POST":
return json({"action": "create", "slug": slug})
return json({"action": "fetch", "slug": slug})
The <user_id:int> converter ensures that only numeric values match the route. If a non-integer is supplied, Sanic returns a 404 automatically. The path converter captures the entire remaining URL, including slashes, which is useful for file-path-style routes.
Working with Request Data
Sanic provides convenient access to all parts of an incoming request through the request object. This includes query parameters, JSON bodies, form data, headers, cookies, and files.
from sanic import Sanic
from sanic.response import json
app = Sanic("RequestDemo")
@app.post("/submit")
async def submit(request):
# JSON body
data = request.json or {}
# Query parameters
page = request.args.get("page", "1")
# Headers
user_agent = request.headers.get("user-agent", "unknown")
# Form data (for application/x-www-form-urlencoded or multipart)
name = request.form.get("name")
# Uploaded files
avatar = request.files.get("avatar")
return json({
"json_body": data,
"page": page,
"user_agent": user_agent,
"name": name,
"has_avatar": avatar is not None,
})
It is important to guard against missing data. Accessing request.json on a request without a JSON body raises an exception, so always provide a fallback or wrap the access in a try/except block.
Responses
Sanic offers several response helper functions for common content types. Each one returns a HTTPResponse object that the framework sends back to the client.
from sanic import Sanic
from sanic.response import json, text, html, raw, redirect
app = Sanic("ResponseDemo")
@app.get("/json")
async def json_response(request):
return json({"status": "ok"}, status=200, headers={"X-Custom": "value"})
@app.get("/text")
async def text_response(request):
return text("Plain text response")
@app.get("/html")
async def html_response(request):
return html("<h1>Hello HTML</h1>")
@app.get("/raw")
async def raw_response(request):
return raw(b"\x89PNG binary data here", content_type="image/png")
@app.get("/old-page")
async def old_page(request):
return redirect("/json")
Streaming Responses
For large responses or server-sent events, you can stream the response body chunk by chunk:
from sanic import Sanic
from sanic.response import streaming
app = Sanic("StreamingDemo")
@app.get("/stream")
async def stream_handler(request):
async def streaming_fn(response):
for i in range(10):
await response.write(f"chunk {i}\n")
await asyncio.sleep(0.5)
await response.write("done\n")
return streaming(streaming_fn, content_type="text/plain")
Blueprints for Modular Applications
As your application grows, putting every route in a single file becomes unmanageable. Sanic's blueprints let you group related routes into modules that you register on the main app.
# users.py
from sanic import Blueprint
from sanic.response import json
users_bp = Blueprint("users", url_prefix="/users")
@users_bp.get("/")
async def list_users(request):
return json({"users": ["alice", "bob"]})
@users_bp.get("/<user_id>")
async def get_user(request, user_id):
return json({"user_id": user_id})
# app.py
from sanic import Sanic
from users import users_bp
app = Sanic("ModularApp")
app.blueprint(users_bp)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
Blueprints support their own middleware, error handlers, and static file registrations, making them a powerful tool for organizing large codebases.
Middleware
Middleware functions run before or after every request. They are perfect for logging, authentication, CORS headers, and request timing. Define them with @app.middleware("request") or @app.middleware("response").
import time
from sanic import Sanic
from sanic.response import json
app = Sanic("MiddlewareDemo")
@app.middleware("request")
async def add_start_time(request):
request.ctx.start_time = time.time()
@app.middleware("response")
async def add_response_time_header(request, response):
duration = time.time() - request.ctx.start_time
response.headers["X-Response-Time"] = f"{duration:.4f}s"
@app.get("/")
async def index(request):
return json({"message": "check the X-Response-Time header"})
The request.ctx object is a per-request context that you can use to pass data between middleware and handlers safely. Each request gets its own fresh context.
Connecting to a Database
Because Sanic is asynchronous, you should pair it with async database drivers. Below is an example using asyncpg for PostgreSQL. The connection pool is created when the application starts and closed when it stops.
from sanic import Sanic
from sanic.response import json
import asyncpg
app = Sanic("DatabaseDemo")
@app.listener("before_server_start")
async def setup_db(app, loop):
app.ctx.db = await asyncpg.create_pool(
host="localhost",
port=5432,
user="postgres",
password="secret",
database="mydb",
min_size=5,
max_size=20,
)
@app.listener("after_server_stop")
async def close_db(app, loop):
await app.ctx.db.close()
@app.get("/users/<user_id:int>")
async def get_user(request, user_id):
async with request.app.ctx.db.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, name, email FROM users WHERE id = $1", user_id
)
if row is None:
return json({"error": "not found"}, status=404)
return json(dict(row))
Using a connection pool is critical for performance. Creating a new database connection per request would overwhelm the database under load. The pool reuses connections across concurrent requests efficiently.
Error Handling
Sanic lets you register custom error handlers for specific HTTP status codes or for all unhandled exceptions. This is useful for returning consistent JSON error responses from an API.
from sanic import Sanic
from sanic.response import json
from sanic.exceptions import NotFound
app = Sanic("ErrorDemo")
@app.exception(NotFound)
async def not_found(request, exception):
return json({"error": "Resource not found", "path": request.path}, status=404)
@app.exception(Exception)
async def server_error(request, exception):
return json({"error": "Internal server error", "detail": str(exception)}, status=500)
@app.get("/boom")
async def boom(request):
raise ValueError("Something went wrong")
Best Practices
- Always use async handlers: Even if a handler does not perform I/O, defining it with
async defkeeps the API consistent and avoids surprises when you later add database calls. - Never call blocking code directly: Calls like
requests.get(),time.sleep(), or synchronous database drivers will block the entire event loop. Use async alternatives or run them in a thread executor withasyncio.to_thread(). - Use connection pools: Database and HTTP client connections should be pooled and reused. Create pools in
before_server_startlisteners and close them inafter_server_stop. - Validate input: Use a validation library like
pydanticor Sanic's built-insanic-extdecorators to validate request bodies before processing them. - Organize with blueprints: Split routes into blueprints by domain (users, orders, auth) to keep the codebase maintainable.
- Run multiple workers: Use
app.run(workers=4)or a process manager to take advantage of multi-core CPUs. Each worker runs its own event loop. - Enable debug mode only in development:
app.run(debug=True)enables auto-reload and detailed error pages, which you should never expose in production. - Use environment variables for configuration: Store secrets, database URLs, and feature flags in environment variables rather than hardcoding them.
Deployment
Sanic can run directly in production using its built-in server, but for robust deployments you should use a process manager. Here is an example using app.run with multiple workers:
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=8000,
workers=4,
access_log=True,
motd=False,
)
For production, consider running Sanic behind a reverse proxy like Nginx or Caddy. The reverse proxy handles TLS termination, static file serving, and rate limiting, while Sanic focuses on application logic. You can also deploy Sanic using Docker, Gunicorn with the Sanic worker class, or serverless platforms that support ASGI.
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Conclusion
Sanic bridges the gap between developer experience and raw performance in the Python web ecosystem. Its Flask-like API makes it easy to pick up, while its asynchronous architecture delivers the throughput needed for modern, high-traffic applications. By understanding routing, request and response handling, blueprints, middleware, database integration, and deployment strategies, you now have everything you need to build production-grade async web services with Sanic. Start small with a single endpoint, add blueprints as your application grows, and always keep the event loop unblocked to get the most out of this powerful framework.