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 String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Book(Base): __tablename__ = "books" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String, nullable=False) author: Mapped[str] = mapped_column(String, nullable=False) year: Mapped[int] = mapped_column(nullable=False) isbn: Mapped[str | None] = mapped_column(String, nullable=True) This uses SQLAlchemy 2.0's ``Mapped`` type annotations and ``mapped_column()``, which give you type checker support and cleaner syntax than the older ``Column()`` style. Each model class corresponds to a table, and each ``mapped_column()`` corresponds to a column. 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.