diff --git a/docs/source/api.rst b/docs/source/api.rst
index 51648dd..fc7851f 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -1,35 +1,60 @@
+API Reference
+=============
-API Documentation
-=================
+This page documents Responder's public Python API. For usage examples
+and explanations, see the :doc:`quickstart` and :doc:`tour`.
-Web Service (API) Class
------------------------
+The API Class
+-------------
+
+The central object of every Responder application. It holds your routes,
+middleware, templates, and configuration. Create one at the top of your
+module and use it to define your entire web service.
+
.. module:: responder
.. autoclass:: API
:inherited-members:
-Requests & Responses
---------------------
+Request
+-------
+
+The request object is passed into every view as the first argument. It
+gives you access to everything the client sent — headers, query
+parameters, the request body, cookies, and more.
+
+Most properties are synchronous, but reading the body requires ``await``
+because it involves I/O.
.. autoclass:: Request
:inherited-members:
+
+Response
+--------
+
+The response object is passed into every view as the second argument.
+Mutate it to control what gets sent back to the client — the body,
+status code, headers, and cookies.
+
.. autoclass:: Response
:inherited-members:
-Utility Functions
------------------
+Status Code Helpers
+-------------------
-.. autofunction:: responder.API.status_codes.is_100
+Convenience functions for checking which category a status code falls
+into. Useful in middleware and after-request hooks.
-.. autofunction:: responder.API.status_codes.is_200
+.. autofunction:: responder.status_codes.is_100
-.. autofunction:: responder.API.status_codes.is_300
+.. autofunction:: responder.status_codes.is_200
-.. autofunction:: responder.API.status_codes.is_400
+.. autofunction:: responder.status_codes.is_300
-.. autofunction:: responder.API.status_codes.is_500
+.. autofunction:: responder.status_codes.is_400
+
+.. autofunction:: responder.status_codes.is_500
diff --git a/docs/source/cli.rst b/docs/source/cli.rst
index 47f3e7e..d7a0447 100644
--- a/docs/source/cli.rst
+++ b/docs/source/cli.rst
@@ -1,174 +1,81 @@
-Responder CLI
-=============
+Command Line Interface
+======================
-Responder installs a command line program ``responder``. Use it to launch
-a Responder application from a file or module, either located on a local
-or remote filesystem, or object store.
-
-Launch Module Entrypoint
-------------------------
-
-For loading a Responder application from a Python module, you will refer to
-its ``API()`` instance using a `Python entry point object reference`_ that
-points to a Python object. It is either in the form ``importable.module``,
-or ``importable.module:object.attr``.
-
-A basic invocation command to launch a Responder application:
-
-.. code-block:: shell
-
- responder run acme.app
-
-The command above assumes a Python package ``acme`` including an ``app``
-module ``acme/app.py`` that includes an attribute ``api`` that refers
-to a ``responder.API`` instance, reflecting the typical layout of
-a standard Responder application.
-
-Loading a Responder application using an entrypoint specification will
-inherit the capacities of `Python's import system`_, as implemented by
-`importlib`_.
-
-Launch Local File
------------------
-
-Acquire a minimal example single-file application, ``helloworld.py`` [1]_,
-to your local filesystem, giving you the chance to edit it, and launch the
-Responder HTTP service.
-
-.. code-block:: shell
-
- wget https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
- responder run helloworld.py
-
-.. note::
-
- To validate the example application, invoke a HTTP request, for example using
- `curl`_, `HTTPie`_, or your favourite browser at hand.
-
- .. code-block:: shell
-
- http http://127.0.0.1:5042/Hello
-
- The response is no surprise.
-
- ::
-
- HTTP/1.1 200 OK
- content-length: 13
- content-type: text/plain
- date: Sat, 26 Oct 2024 13:16:55 GMT
- encoding: utf-8
- server: uvicorn
-
- Hello, world!
-
-.. [1] The Responder application `helloworld.py`_ implements a basic echo handler.
-
-Launch Remote File
-------------------
-
-You can also launch a single-file application where its Python file is stored
-on a remote location.
-
-Responder supports all filesystem adapters compatible with `fsspec`_, and
-installs the adapters for Azure Blob Storage (az), Google Cloud Storage (gs),
-GitHub, HTTP, and AWS S3 by default.
-
-.. code-block:: shell
-
- # Works 1:1.
- responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
- responder run github://kennethreitz:responder@/examples/helloworld.py
-
-If you need access other kinds of remote targets, see the `list of
-fsspec-supported filesystems and protocols`_. The next section enumerates
-a few synthetic examples. The corresponding storage buckets do not even
-exist, so don't expect those commands to work.
-
-.. code-block:: shell
-
- # Azure Blob Storage, Google Cloud Storage, and AWS S3.
- responder run az://kennethreitz-assets/responder/examples/helloworld.py
- responder run gs://kennethreitz-assets/responder/examples/helloworld.py
- responder run s3://kennethreitz-assets/responder/examples/helloworld.py
-
- # Hadoop Distributed File System (hdfs), SSH File Transfer Protocol (sftp),
- # Common Internet File System (smb), Web-based Distributed Authoring and
- # Versioning (webdav).
- responder run hdfs://kennethreitz-assets/responder/examples/helloworld.py
- responder run sftp://user@host/kennethreitz/responder/examples/helloworld.py
- responder run smb://workgroup;user:password@server:port/responder/examples/helloworld.py
- responder run webdav+https://user:password@server:port/responder/examples/helloworld.py
-
-.. tip::
-
- In order to install support for all filesystem types supported by fsspec, run:
-
- .. code-block:: shell
-
- uv pip install 'fsspec[full]'
-
- When using ``uv``, this concludes within an acceptable time of approx.
- 25 seconds. If you need to be more selectively instead of using ``full``,
- choose from one or multiple of the available `fsspec extras`_, which are:
-
- abfs, arrow, dask, dropbox, fuse, gcs, git, github, hdfs, http, oci, s3,
- sftp, smb, ssh.
-
-Launch with Non-Standard Instance Name
---------------------------------------
-
-By default, Responder will acquire an ``responder.API`` instance using the
-symbol name ``api`` from the specified Python module.
-
-If your main application file uses a different name than ``api``, please
-append the designated symbol name to the launch target address.
-
-It works like this for module entrypoints and local files:
-
-.. code-block:: shell
-
- responder run acme.app:service
- responder run /path/to/acme/app.py:service
-
-It works like this for URLs:
-
-.. code-block:: shell
-
- responder run http://app.server.local/path/to/acme/app.py#service
-
-Within your ``app.py``, the instance would have been defined to use
-the ``service`` symbol name instead of ``api``, like this:
-
-.. code-block:: python
-
- service = responder.API()
-
-Build JavaScript Application
-----------------------------
-
-The ``build`` subcommand invokes ``npm run build``, optionally accepting
-a target directory. By default, it uses the current working directory,
-where it expects a regular NPM ``package.json`` file.
-
-.. code-block:: shell
-
- responder build
-
-When specifying a target directory, Responder will change to that
-directory beforehand.
-
-.. code-block:: shell
-
- responder build /path/to/project
+Responder installs a ``responder`` command that lets you launch
+applications from the terminal. You can point it at a Python module,
+a local file, or even a URL — and it will find your ``API`` instance
+and start serving.
-.. _curl: https://curl.se/
-.. _fsspec: https://filesystem-spec.readthedocs.io/en/latest/
-.. _fsspec extras: https://github.com/fsspec/filesystem_spec/blob/2024.12.0/pyproject.toml#L27-L69
-.. _helloworld.py: https://github.com/kennethreitz/responder/blob/main/examples/helloworld.py
-.. _HTTPie: https://httpie.io/docs/cli
-.. _importlib: https://docs.python.org/3/library/importlib.html
-.. _list of fsspec-supported filesystems and protocols: https://github.com/fsspec/universal_pathlib#currently-supported-filesystems-and-protocols
-.. _Python entry point object reference: https://packaging.python.org/en/latest/specifications/entry-points/
-.. _Python's import system: https://docs.python.org/3/reference/import.html
+Launching from a Module
+-----------------------
+
+The most common way to run a Responder application in production. Use
+Python's standard dotted module path::
+
+ $ responder run acme.app
+
+This imports ``acme.app`` and looks for an attribute called ``api``
+(a ``responder.API`` instance). It's the same import system Python
+uses everywhere — your ``PYTHONPATH`` and virtual environment are
+respected.
+
+
+Launching from a File
+---------------------
+
+During development, you often have a single file you want to run::
+
+ $ responder run helloworld.py
+
+This loads the file directly and starts the server. Quick and easy for
+prototyping and single-file applications.
+
+You can test it with a simple HTTP request::
+
+ $ curl http://127.0.0.1:5042/hello
+ hello, world!
+
+
+Launching from a URL
+--------------------
+
+Responder can fetch and run a Python file from any URL — great for
+demos, sharing examples, and running code from GitHub::
+
+ $ responder run https://github.com/kennethreitz/responder/raw/refs/heads/main/examples/helloworld.py
+
+This also works with ``github://`` URLs and any filesystem protocol
+supported by `fsspec `_::
+
+ $ responder run github://kennethreitz:responder@/examples/helloworld.py
+
+Cloud storage is supported too — Azure Blob Storage, Google Cloud
+Storage, S3, HDFS, SFTP, and more. Install ``fsspec[full]`` for all
+protocols::
+
+ $ uv pip install 'fsspec[full]'
+
+
+Custom Instance Names
+---------------------
+
+By default, Responder looks for an attribute called ``api``. If your
+application uses a different name, specify it with a colon::
+
+ $ responder run acme.app:service
+ $ responder run myapp.py:application
+
+For URLs, use a fragment::
+
+ $ responder run https://example.com/app.py#service
+
+
+Building Frontend Assets
+-------------------------
+
+If your project includes a JavaScript frontend with a ``package.json``,
+the ``build`` subcommand runs ``npm run build``::
+
+ $ responder build
+ $ responder build /path/to/frontend
diff --git a/docs/source/index.rst b/docs/source/index.rst
index aa907a1..7455866 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -100,6 +100,14 @@ Python 3.9 and above. That's it.
api
cli
+.. toctree::
+ :maxdepth: 2
+ :caption: Tutorials
+
+ tutorial-rest
+ tutorial-sqlalchemy
+ tutorial-flask
+
.. toctree::
:maxdepth: 1
:caption: Project
diff --git a/docs/source/tutorial-flask.rst b/docs/source/tutorial-flask.rst
new file mode 100644
index 0000000..d6bf14d
--- /dev/null
+++ b/docs/source/tutorial-flask.rst
@@ -0,0 +1,192 @@
+Migrating from Flask
+====================
+
+If you're coming from Flask, you'll find Responder familiar but different
+in a few key ways. This guide maps Flask concepts to their Responder
+equivalents and shows you how to translate common patterns.
+
+
+The Big Differences
+-------------------
+
+**No return values.** In Flask, you return a response. In Responder, you
+mutate it. This is the single biggest difference:
+
+Flask::
+
+ @app.route("/")
+ def hello():
+ return "hello, world!"
+
+Responder::
+
+ @api.route("/")
+ def hello(req, resp):
+ resp.text = "hello, world!"
+
+**Explicit request and response.** Flask uses a global ``request`` object
+(via thread-local magic). Responder passes ``req`` and ``resp`` explicitly.
+No magic, no import needed — they're right there in the function signature.
+
+**ASGI, not WSGI.** Flask runs on WSGI, which is synchronous. Responder
+runs on ASGI, which supports async natively. You can still write sync
+views — Responder runs them in a thread pool automatically.
+
+
+Quick Reference
+---------------
+
+.. list-table::
+ :header-rows: 1
+ :widths: 40 60
+
+ * - Flask
+ - Responder
+ * - ``Flask(__name__)``
+ - ``responder.API()``
+ * - ``return "text"``
+ - ``resp.text = "text"``
+ * - ``return jsonify(data)``
+ - ``resp.media = data``
+ * - ``return render_template("t.html", x=1)``
+ - ``resp.html = api.template("t.html", x=1)``
+ * - ``request.args["q"]``
+ - ``req.params["q"]``
+ * - ``request.json``
+ - ``await req.media()``
+ * - ``request.form``
+ - ``await req.media("form")``
+ * - ``request.headers["X"]``
+ - ``req.headers["X"]``
+ * - ``request.method``
+ - ``req.method``
+ * - ``request.cookies["x"]``
+ - ``req.cookies["x"]``
+ * - ``session["x"] = 1``
+ - ``resp.session["x"] = 1``
+ * - ``abort(404)``
+ - ``resp.status_code = 404``
+ * - ``redirect("/new")``
+ - ``api.redirect(resp, location="/new")``
+ * - ``@app.before_request``
+ - ``@api.route(before_request=True)``
+ * - ``@app.errorhandler(404)``
+ - ``@api.exception_handler(ValueError)``
+ * - ``app.run(debug=True)``
+ - ``api.run(debug=True)``
+
+
+Route Parameters
+----------------
+
+Flask uses ````. Responder uses ``{curly_braces}``
+with the same type convertor idea:
+
+Flask::
+
+ @app.route("/users/")
+ def get_user(user_id):
+ return jsonify({"id": user_id})
+
+Responder::
+
+ @api.route("/users/{user_id:int}")
+ def get_user(req, resp, *, user_id):
+ resp.media = {"id": user_id}
+
+Note the ``*`` — route parameters are keyword-only arguments in
+Responder. This makes the interface explicit about which arguments
+come from the URL.
+
+
+JSON APIs
+---------
+
+Flask::
+
+ @app.route("/api/items", methods=["POST"])
+ def create_item():
+ data = request.json
+ # ... create item
+ return jsonify(item), 201
+
+Responder::
+
+ @api.route("/api/items", methods=["POST"])
+ async def create_item(req, resp):
+ data = await req.media()
+ # ... create item
+ resp.media = item
+ resp.status_code = 201
+
+The ``await`` is needed because reading the request body is an async
+I/O operation. This is more explicit than Flask's approach, and it
+means the event loop isn't blocked while waiting for the body to arrive.
+
+
+Templates
+---------
+
+Both use Jinja2. The syntax is nearly identical:
+
+Flask::
+
+ @app.route("/hello/")
+ def hello(name):
+ return render_template("hello.html", name=name)
+
+Responder::
+
+ @api.route("/hello/{name}")
+ def hello(req, resp, *, name):
+ resp.html = api.template("hello.html", name=name)
+
+
+Blueprints → Route Groups
+--------------------------
+
+Flask uses Blueprints to organize routes. Responder has route groups:
+
+Flask::
+
+ bp = Blueprint("api", __name__, url_prefix="/api")
+
+ @bp.route("/users")
+ def list_users():
+ return jsonify([])
+
+ app.register_blueprint(bp)
+
+Responder::
+
+ api_v1 = api.group("/api")
+
+ @api_v1.route("/users")
+ def list_users(req, resp):
+ resp.media = []
+
+
+Gradual Migration
+-----------------
+
+You don't have to migrate all at once. Responder can mount your existing
+Flask app at a subroute, so you can move endpoints over one at a time::
+
+ from flask import Flask
+
+ flask_app = Flask(__name__)
+
+ # Your existing Flask routes stay here
+ @flask_app.route("/legacy")
+ def legacy():
+ return "old endpoint"
+
+ # Mount Flask under /old, new routes go on Responder
+ api.mount("/old", flask_app)
+
+ @api.route("/new")
+ def new_endpoint(req, resp):
+ resp.media = {"modern": True}
+
+Requests to ``/old/legacy`` go to Flask. Everything else goes to
+Responder. When you've moved everything over, remove the mount.
diff --git a/docs/source/tutorial-rest.rst b/docs/source/tutorial-rest.rst
new file mode 100644
index 0000000..ec95f4b
--- /dev/null
+++ b/docs/source/tutorial-rest.rst
@@ -0,0 +1,219 @@
+Building a REST API
+===================
+
+This tutorial walks you through building a complete REST API from scratch.
+By the end, you'll have a working API with CRUD operations, request
+validation, error handling, and interactive documentation.
+
+We'll build a simple book catalog — a service that lets you create, read,
+update, and delete books.
+
+
+Project Setup
+-------------
+
+Create a new file called ``app.py``::
+
+ import responder
+
+ api = responder.API(
+ title="Book Catalog",
+ version="1.0",
+ openapi="3.0.2",
+ docs_route="/docs",
+ )
+
+We're enabling OpenAPI documentation from the start. Visit ``/docs`` at
+any point to see interactive Swagger UI for your API.
+
+
+Define Your Models
+------------------
+
+We'll use `Pydantic `_ to define our data
+models. Pydantic models serve double duty — they validate incoming data
+*and* generate OpenAPI schemas automatically::
+
+ from pydantic import BaseModel
+
+ class BookIn(BaseModel):
+ """What the client sends when creating a book."""
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+ class Book(BaseModel):
+ """What the API returns."""
+ id: int
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+``BookIn`` is the *input* model — it doesn't have an ``id`` because the
+server assigns that. ``Book`` is the *output* model — it includes
+everything. This input/output separation is a common REST API pattern.
+
+
+In-Memory Storage
+-----------------
+
+For this tutorial, we'll store books in a simple dict. In a real
+application, you'd use a database (see :doc:`tutorial-sqlalchemy`)::
+
+ books_db: dict[int, dict] = {}
+ next_id = 1
+
+
+List All Books
+--------------
+
+The first endpoint — list all books. This is a ``GET`` request to
+``/books``::
+
+ @api.route("/books", methods=["GET"], response_model=list)
+ def list_books(req, resp):
+ resp.media = list(books_db.values())
+
+In REST API design, ``GET`` requests should never modify data. They're
+*safe* and *idempotent* — calling them multiple times has the same effect
+as calling them once.
+
+
+Create a Book
+-------------
+
+To create a book, the client sends a ``POST`` request with a JSON body.
+We use ``request_model=BookIn`` to validate the input automatically — if
+the client sends bad data, they get a ``422`` response with error details::
+
+ @api.route("/books", methods=["POST"], check_existing=False,
+ request_model=BookIn, response_model=Book)
+ async def create_book(req, resp):
+ global next_id
+ data = await req.media()
+
+ book = {"id": next_id, **data}
+ books_db[next_id] = book
+ next_id += 1
+
+ resp.media = book
+ resp.status_code = 201
+
+Note ``resp.status_code = 201`` — the HTTP ``201 Created`` status code
+tells the client that a new resource was successfully created. This is
+more informative than a generic ``200 OK``.
+
+
+Get a Single Book
+-----------------
+
+Retrieve a specific book by its ID. The ``{book_id:int}`` route parameter
+ensures only integer IDs match — requests like ``/books/abc`` will 404::
+
+ @api.route("/books/{book_id:int}", methods=["GET"], response_model=Book)
+ def get_book(req, resp, *, book_id):
+ if book_id not in books_db:
+ resp.status_code = 404
+ resp.media = {"error": f"Book {book_id} not found"}
+ return
+
+ resp.media = books_db[book_id]
+
+
+Update a Book
+-------------
+
+``PUT`` replaces a resource entirely. The client must send all fields::
+
+ @api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
+ request_model=BookIn, response_model=Book)
+ async def update_book(req, resp, *, book_id):
+ if book_id not in books_db:
+ resp.status_code = 404
+ resp.media = {"error": f"Book {book_id} not found"}
+ return
+
+ data = await req.media()
+ book = {"id": book_id, **data}
+ books_db[book_id] = book
+ resp.media = book
+
+
+Delete a Book
+-------------
+
+``DELETE`` removes a resource. The convention is to return ``204 No Content``
+with an empty body on success::
+
+ @api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
+ def delete_book(req, resp, *, book_id):
+ if book_id not in books_db:
+ resp.status_code = 404
+ resp.media = {"error": f"Book {book_id} not found"}
+ return
+
+ del books_db[book_id]
+ resp.status_code = 204
+
+
+Error Handling
+--------------
+
+Let's add a custom error handler so any ``ValueError`` in our code returns
+a clean JSON response instead of a 500 error::
+
+ @api.exception_handler(ValueError)
+ async def handle_value_error(req, resp, exc):
+ resp.status_code = 400
+ resp.media = {"error": str(exc)}
+
+
+Run It
+------
+
+Add the standard entry point at the bottom of your file::
+
+ if __name__ == "__main__":
+ api.run()
+
+Start the server::
+
+ $ python app.py
+
+Visit ``http://localhost:5042/docs`` to see your interactive API
+documentation. You can test every endpoint directly from the browser.
+
+
+Try It Out
+----------
+
+Using ``curl``::
+
+ # Create a book
+ $ curl -X POST http://localhost:5042/books \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Dune", "author": "Frank Herbert", "year": 1965}'
+
+ # List all books
+ $ curl http://localhost:5042/books
+
+ # Get a specific book
+ $ curl http://localhost:5042/books/1
+
+ # Update a book
+ $ curl -X PUT http://localhost:5042/books/1 \
+ -H "Content-Type: application/json" \
+ -d '{"title": "Dune", "author": "Frank Herbert", "year": 1965, "isbn": "978-0441172719"}'
+
+ # Delete a book
+ $ curl -X DELETE http://localhost:5042/books/1
+
+
+What's Next
+-----------
+
+This tutorial used in-memory storage. For a real application, you'll want
+a database. See :doc:`tutorial-sqlalchemy` for how to integrate SQLAlchemy
+with Responder using the lifespan pattern.
diff --git a/docs/source/tutorial-sqlalchemy.rst b/docs/source/tutorial-sqlalchemy.rst
new file mode 100644
index 0000000..3bdcabd
--- /dev/null
+++ b/docs/source/tutorial-sqlalchemy.rst
@@ -0,0 +1,254 @@
+Using SQLAlchemy
+================
+
+Most real web applications need a database. This guide shows how to
+integrate `SQLAlchemy `_ with Responder,
+using async support and the lifespan pattern for connection management.
+
+SQLAlchemy is the most popular Python database toolkit. It gives you an
+ORM (Object-Relational Mapper) for working with databases using Python
+classes instead of raw SQL, plus a powerful query builder for when you
+need fine-grained control.
+
+
+Installation
+------------
+
+Install SQLAlchemy with async support and an async database driver.
+We'll use SQLite for simplicity, but the pattern works with PostgreSQL,
+MySQL, and any other database SQLAlchemy supports::
+
+ $ uv pip install 'sqlalchemy[asyncio]' aiosqlite
+
+
+Define Your Models
+------------------
+
+SQLAlchemy models map Python classes to database tables. Each attribute
+becomes a column::
+
+ # models.py
+ from sqlalchemy import Column, Integer, String
+ from sqlalchemy.orm import DeclarativeBase
+
+ class Base(DeclarativeBase):
+ pass
+
+ class Book(Base):
+ __tablename__ = "books"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ title = Column(String, nullable=False)
+ author = Column(String, nullable=False)
+ year = Column(Integer, nullable=False)
+ isbn = Column(String, nullable=True)
+
+``DeclarativeBase`` is SQLAlchemy's modern base class (SQLAlchemy 2.0+).
+Each model class corresponds to a table, and each ``Column`` corresponds
+to a column in that table.
+
+
+Database Setup
+--------------
+
+Create the async engine and session factory. The *engine* manages
+the connection pool. The *session* is your unit of work — you use it to
+query and modify data within a transaction::
+
+ # database.py
+ from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
+
+ DATABASE_URL = "sqlite+aiosqlite:///./books.db"
+
+ engine = create_async_engine(DATABASE_URL, echo=True)
+ async_session = async_sessionmaker(engine, expire_on_commit=False)
+
+The ``echo=True`` flag prints all SQL queries to the console — very
+helpful during development, but you'll want to disable it in production.
+
+The ``expire_on_commit=False`` flag keeps model attributes accessible
+after a commit, which is convenient for returning created objects in
+API responses.
+
+
+Lifespan for Startup and Shutdown
+----------------------------------
+
+Use Responder's lifespan context manager to create the database tables
+on startup and dispose of connections on shutdown::
+
+ # app.py
+ from contextlib import asynccontextmanager
+ import responder
+ from database import engine
+ from models import Base
+
+ @asynccontextmanager
+ async def lifespan(app):
+ # Startup: create tables
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ yield
+ # Shutdown: close all connections
+ await engine.dispose()
+
+ api = responder.API(lifespan=lifespan)
+
+This is the proper way to manage database connections in an async
+application. The lifespan context manager ensures that:
+
+1. Tables are created before the first request
+2. The connection pool is properly closed when the server shuts down
+3. If table creation fails, the server won't start
+
+
+CRUD Endpoints
+--------------
+
+Now let's build the API endpoints. Each one opens a database session,
+does its work, and commits or rolls back::
+
+ from pydantic import BaseModel
+ from sqlalchemy import select
+ from database import async_session
+ from models import Book
+
+ # Pydantic models for request/response validation
+ class BookIn(BaseModel):
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+ class BookOut(BaseModel):
+ id: int
+ title: str
+ author: str
+ year: int
+ isbn: str | None = None
+
+ class Config:
+ from_attributes = True
+
+The ``from_attributes = True`` config tells Pydantic to read data from
+SQLAlchemy model attributes (not just dicts). This lets you pass a
+SQLAlchemy ``Book`` object directly to ``BookOut``.
+
+**List all books**::
+
+ @api.route("/books", methods=["GET"])
+ async def list_books(req, resp):
+ async with async_session() as session:
+ result = await session.execute(select(Book))
+ books = result.scalars().all()
+ resp.media = [BookOut.model_validate(b).model_dump() for b in books]
+
+**Create a book**::
+
+ @api.route("/books", methods=["POST"], check_existing=False,
+ request_model=BookIn, response_model=BookOut)
+ async def create_book(req, resp):
+ data = await req.media()
+
+ async with async_session() as session:
+ book = Book(**data)
+ session.add(book)
+ await session.commit()
+ await session.refresh(book)
+ resp.media = BookOut.model_validate(book).model_dump()
+ resp.status_code = 201
+
+**Get a single book**::
+
+ @api.route("/books/{book_id:int}", methods=["GET"])
+ async def get_book(req, resp, *, book_id):
+ async with async_session() as session:
+ book = await session.get(Book, book_id)
+ if book is None:
+ resp.status_code = 404
+ resp.media = {"error": "Book not found"}
+ return
+ resp.media = BookOut.model_validate(book).model_dump()
+
+**Update a book**::
+
+ @api.route("/books/{book_id:int}", methods=["PUT"], check_existing=False,
+ request_model=BookIn)
+ async def update_book(req, resp, *, book_id):
+ data = await req.media()
+
+ async with async_session() as session:
+ book = await session.get(Book, book_id)
+ if book is None:
+ resp.status_code = 404
+ resp.media = {"error": "Book not found"}
+ return
+
+ for key, value in data.items():
+ setattr(book, key, value)
+
+ await session.commit()
+ await session.refresh(book)
+ resp.media = BookOut.model_validate(book).model_dump()
+
+**Delete a book**::
+
+ @api.route("/books/{book_id:int}", methods=["DELETE"], check_existing=False)
+ async def delete_book(req, resp, *, book_id):
+ async with async_session() as session:
+ book = await session.get(Book, book_id)
+ if book is None:
+ resp.status_code = 404
+ resp.media = {"error": "Book not found"}
+ return
+
+ await session.delete(book)
+ await session.commit()
+ resp.status_code = 204
+
+
+Run It
+------
+
+::
+
+ if __name__ == "__main__":
+ api.run()
+
+Start the server and you'll see SQLAlchemy's SQL echo in the console.
+The SQLite database file ``books.db`` is created automatically on first
+startup.
+
+
+Using PostgreSQL
+----------------
+
+To switch to PostgreSQL, just change the connection URL and driver::
+
+ $ uv pip install asyncpg
+
+::
+
+ DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
+
+Everything else stays the same. SQLAlchemy abstracts the database
+differences so your application code doesn't need to change.
+
+
+Tips
+----
+
+- Use ``async with async_session() as session`` for every request.
+ Don't share sessions across requests — each request should get its
+ own session and transaction.
+
+- For complex queries, use SQLAlchemy's ``select()`` with ``.where()``,
+ ``.order_by()``, ``.limit()``, and ``.offset()`` — it composes
+ naturally.
+
+- In production, use connection pooling (SQLAlchemy does this by
+ default) and set pool size limits appropriate for your database.
+
+- Consider `Alembic `_ for database
+ migrations — it tracks schema changes over time so you can evolve
+ your database without losing data.