Skip to main content
FastAPI is an async Python web framework, and SQLAlchemy is the most widely used Python ORM. In this guide, you define a typed SQLAlchemy model, create its table with an Alembic migration, and serve create, read, update, and delete endpoints with FastAPI. The app uses the asyncpg driver, and every connection uses TLS with full certificate verification.

Prerequisites

  • uv and Python 3.11 or later. This guide was tested with Python 3.13, FastAPI 0.142, SQLAlchemy 2.1, asyncpg 0.31, Alembic 1.20, and Uvicorn 0.54.
  • A ClickHouse Cloud account
  • psql, to create the database. You can also run the CREATE DATABASE statement in the SQL console.

Create a ClickHouse Managed Postgres service

In the ClickHouse Cloud console, click New service and select Postgres. The instance is ready in a few minutes. See the quickstart for a walkthrough.

Get your connection details

Click Connect in the left sidebar of your service. The modal shows your username, password, server, and port, and it has a Directly / via PgBouncer toggle. This guide uses both connections:
  • PgBouncer (port 6432) for the app. SQLAlchemy keeps a pool of connections in every Uvicorn worker, and the bundled PgBouncer lets many workers and replicas share a small number of Postgres connections. It runs in transaction pooling mode.
  • Direct (port 5432) for Alembic. Migrations run DDL, can run for a long time, and may depend on session state, so they should talk to Postgres directly.
Turn on Use SSL and click Download CA certificate. You can also download it from Settings → CA Certificate. The certificate is unique to your instance, so the driver can use it to verify that it’s talking to your server. With Use SSL on, the URL in the modal ends in sslmode=verify-full&sslrootcert=.... asyncpg doesn’t read these parameters, so copy only the password and host from the modal. The app passes the CA certificate in code instead (see Configure the connection).

Set up the project

Create a project and install FastAPI, Uvicorn, SQLAlchemy with its asyncio extra, asyncpg, and Alembic:
Adding to an existing app?Skip the uv init and mkdir commands. In your project, run uv add "sqlalchemy[asyncio]" asyncpg alembic, then continue with Configure the connection. If your app uses SQLite through async SQLAlchemy:
  • Change your create_async_engine call to match the one in app/db.py. In migrations/env.py, import Base and your models from your own modules.
  • Remove Base.metadata.create_all from startup. Alembic creates your existing tables in Postgres, but it doesn’t copy data from SQLite.

Configure the connection

Move the CA certificate you downloaded into the project directory and rename it to ca-certificate.pem. Create a database for the app. Replace <PASSWORD> and the host with the values from the Connect modal:
Create a .env file in the project root, or add these variables to your existing one. The two URLs differ only in the port, use the postgresql+asyncpg scheme, and have no SSL parameters:
.env
DATABASE_CA_CERT is resolved relative to the directory you run commands from. If your password contains special characters such as @, /, or #, URL-encode them. Keep .env out of version control. Create app/db.py. It builds the TLS settings, the engine, and a session factory that the rest of the app shares:
app/db.py
An ssl.SSLContext created with PROTOCOL_TLS_CLIENT requires a valid certificate and checks the hostname, which is the equivalent of sslmode=verify-full. SQLAlchemy passes it to asyncpg through connect_args.
Pass the CA certificate in connect_args
  • Without an ssl argument, asyncpg uses TLS if the server offers it, but it doesn’t verify the certificate.
  • Don’t copy sslmode or sslrootcert from the Connect modal into the URL. SQLAlchemy passes URL parameters to asyncpg as keyword arguments, and the connection fails with TypeError: connect() got an unexpected keyword argument 'sslmode'.
  • On Python 3.13 and later, a context from ssl.create_default_context fails with certificate verify failed: Missing Authority Key Identifier, because that function enables strict X.509 checks. Create the context with ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) as shown.
  • If the CA certificate is wrong, the connection fails with certificate verify failed: unable to get local issuer certificate. If the hostname doesn’t match, for example when you connect by IP address, it fails with certificate verify failed: IP address mismatch.
If you’d rather keep the TLS settings in the URL, or your app uses synchronous SQLAlchemy sessions, use the psycopg driver instead of asyncpg. It’s built on libpq and accepts the parameters from the Connect modal, for example postgresql+psycopg://...:5432/guide_fastapi?sslmode=verify-full&sslrootcert=ca-certificate.pem, with both create_engine and create_async_engine.

Define the model

Create app/models.py with a typed SQLAlchemy model:
app/models.py

Run migrations

Create an Alembic environment from the async template:
Replace the contents of the generated migrations/env.py. The new version loads your models for autogenerate and connects directly to Postgres with the same TLS settings as the app:
migrations/env.py
Alembic ignores the placeholder sqlalchemy.url in alembic.ini, because env.py builds the engine itself. Generate a migration from the model, then apply it:
Review the generated file in migrations/versions and commit it with your code. Alembic records the applied revision in the alembic_version table. Each time you change a model, run revision --autogenerate and upgrade head again.

Add the API endpoints

Create app/main.py. Each request gets its own AsyncSession through a FastAPI dependency, and the engine’s pool is closed when the app shuts down:
app/main.py
SQLAlchemy runs the statements of each session in one transaction, which ends at session.commit. When the session closes, any uncommitted work is rolled back. On INSERT, SQLAlchemy reads the generated id and created_at back with RETURNING, so create_todo doesn’t need an extra query.

Run and verify

Start the app:
In a second terminal, create two todos, mark the first one done, and delete the second:
The DELETE request returns 204 No Content. Requesting a todo that doesn’t exist returns 404:
Open http://localhost:3112/todos in your browser to list the remaining todos, or http://localhost:3112/docs to try the endpoints in FastAPI’s interactive API docs. To confirm that the direct connection uses verified TLS, query pg_stat_ssl with the same TLS settings as the app:
To see the data in the console, open SQL console in the left sidebar of your service, expand guide_fastapi and then public, and click the todos table. The alembic_version table next to it holds the migration history. The todos table contains the remaining row: Stop the app with Ctrl+C.

Using PgBouncer

The app works through PgBouncer with the default asyncpg and SQLAlchemy settings:
  • asyncpg runs every query as a named, protocol-level prepared statement, and SQLAlchemy caches up to 100 of them per connection. The bundled PgBouncer supports these statements in transaction pooling mode, and asyncpg frees them with a protocol-level Close message rather than SQL DEALLOCATE. You don’t need statement_cache_size=0 or a custom prepared_statement_name_func. If you turn off asyncpg’s statement cache anyway, also add prepared_statement_cache_size=0 to the URL to turn off SQLAlchemy’s cache.
  • Session settings made with SET, such as search_path, don’t carry over to the next transaction and can leak to other clients. Use SET LOCAL inside a transaction instead.
  • pg_stat_ssl reports the connection between PgBouncer and Postgres, so the TLS check above returns (False, None) through port 6432. The app’s connection to PgBouncer is still verified: a wrong CA certificate fails in the same way.
  • Alembic doesn’t take advisory locks and runs each upgrade in one transaction, so it also works through PgBouncer. The direct connection is still the better choice for long migrations.
Changing a column’s typePgBouncer keeps the prepared statements on its Postgres connections. After a migration changes the type of a column that a query returns, such as integer to bigint, that query fails through PgBouncer with InvalidCachedStatementError: cached statement plan is invalid due to a database schema or configuration change, even from new connections, until PgBouncer replaces its Postgres connections. To recover, close PgBouncer’s Postgres connections for the database over the direct connection, as the same user the app connects as. PgBouncer connects to Postgres locally, so its connections have no client_addr; the query leaves direct connections alone. PgBouncer opens new connections, and each pooled app connection may fail one more time before SQLAlchemy refreshes its cache:
Adding a column doesn’t cause this, because SQLAlchemy lists the columns it selects.

Next steps

  • Connection: connection strings, PgBouncer, and TLS
  • Settings: Postgres and PgBouncer parameters, such as max_connections
  • Read replicas: send read-only queries to a replica with a second engine
  • Security: IP access lists and private networking
Last modified on October 1, 2026