Skip to main content
sqlx is an async, pure-Rust SQL toolkit with a built-in connection pool and migrations. In this guide, you create a todos table with a sqlx-cli migration and serve create, read, update, and delete queries from a small axum server. Every connection uses TLS with full certificate verification.

Prerequisites

  • Rust 1.94 or later. This guide was tested with Rust 1.98.1, sqlx 0.9.0, sqlx-cli 0.9.0, and axum 0.8.9.
  • 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. Keep Directly selected and turn on Use SSL. The connection URL on the url tab now ends in sslmode=verify-full&sslrootcert=<service-name>-ca-certificate.pem, and the port is 5432. Click Download CA certificate. You can also download it later 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. This guide connects directly to Postgres on port 5432, for both the app and sqlx-cli. An axum server is a long-lived process, and PgPool keeps its own pool of connections, so it doesn’t need a second pooler in front of Postgres. Migrations should always use the direct connection. If you run many app instances, see Use PgBouncer.

Set up the project

Create a project and add sqlx with the Postgres driver, the Tokio runtime, and the rustls TLS backend, plus axum and a few helper crates:
Install sqlx-cli with the same TLS backend:
Use rustls, not native-tls, on macOSClickHouse Managed Postgres accepts only TLS 1.3. On macOS, the native-tls backend uses the system’s Secure Transport library, which doesn’t support TLS 1.3. A plain cargo install sqlx-cli uses native-tls, and every command then fails with error occurred while attempting to establish a TLS connection: One or more parameters passed to a function were not valid. (or bad protocol version). The same applies to an app built with the tls-native-tls feature. On Linux, native-tls uses OpenSSL and connects, but it loads only the first certificate from ca-certificate.pem. rustls supports TLS 1.3 on every platform and loads every certificate in the file.
Adding to an existing app?Skip cargo new. In your project, run the cargo add and cargo install commands above, then continue with Configure the connection. If the app uses sqlx with SQLite:
  • Remove sqlite from the sqlx features in Cargo.toml, and replace SqlitePool with PgPool.
  • Change ? placeholders to $1, $2, and so on.
  • Rewrite your migrations in Postgres syntax, for example bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY instead of INTEGER PRIMARY KEY AUTOINCREMENT.

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 update DATABASE_URL in your existing one, so that it points at the guide_rust database. Both the app and sqlx-cli read DATABASE_URL from it:
.env
sqlx reads sslmode and sslrootcert from the URL. With verify-full, it checks that the server certificate is signed by your instance’s CA and that it matches the hostname. The sslrootcert path is resolved relative to the directory you run commands from. Keep .env out of version control, and if your password contains characters such as @, /, or #, percent-encode them.
Always set sslmode=verify-fullWithout sslmode, sqlx uses prefer: it encrypts the connection but accepts any certificate, even with sslrootcert set. With verify-full, a missing or wrong CA certificate fails with UnknownIssuer, and a host that doesn’t match the certificate, for example an IP address, fails with NotValidForNameContext (certificate not valid for name in sqlx-cli). A wrong sslrootcert path fails with No such file or directory.

Create the table with a migration

Create a migration file:
Open the new file in the migrations directory and add the table definition:
migrations/<timestamp>_create_todos.sql
Apply it:
sqlx migrate run records applied migrations in the _sqlx_migrations table and only runs new ones on later calls. Commit the migrations directory with your code. To apply migrations when the app starts instead, call sqlx::migrate!().run(&pool).await? in main, as long as the pool uses the direct connection.

Build the API

Replace src/main.rs with a small axum server that runs one query per route. In an existing app, create the pool the same way as in main, and add the Todo struct, the handlers, and the routes to your own router:
src/main.rs
Values are always passed with .bind as parameters ($1), never formatted into the SQL string. Each distinct SQL string is prepared once per connection and kept in a statement cache of 100 statements, which sqlx closes when it evicts them.
Compile-time checked queriesThis guide uses the query_as function, which checks the SQL when it runs, so cargo build doesn’t need a database. The query! and query_as! macros check the SQL and the column types against the database at compile time instead. They read DATABASE_URL from .env during the build, so keep it on the direct connection. To build without a database, for example in CI, run cargo sqlx prepare, commit the generated .sqlx directory, and set SQLX_OFFLINE=true.

Run and verify

Start the server:
The first line comes from pg_stat_ssl, and confirms that the session is encrypted. In a second terminal, create two todos, mark the first one done, and delete the second:
Each request returns the affected row. The -w '\n' option only adds a line break after each response:
Open http://localhost:3117/todos in your browser to list the remaining todos:
To see the data in the console, open SQL console in the left sidebar of your service, expand guide_rust and then public, and click the todos table. The _sqlx_migrations table next to it holds the migration history. The todos table contains the remaining row:

Use PgBouncer

To connect the app through the bundled PgBouncer, select via PgBouncer in the Connect modal and use port 6432 in the app’s DATABASE_URL. The same CA certificate works. For example, keep .env on the direct connection for sqlx-cli and set the variable when you start the app:
Through PgBouncer, pg_stat_ssl reports PgBouncer’s own connection to Postgres, so the check prints none. Your app’s connection to PgBouncer is still verified: a wrong CA certificate fails with UnknownIssuer in the same way. PgBouncer runs in transaction pooling mode, so each transaction can run on a different Postgres connection. With sqlx:
  • Connecting requires .extra_float_digits(None), as in main.rs. By default, sqlx sends extra_float_digits as a startup parameter, and PgBouncer rejects the connection with unsupported startup parameter: extra_float_digits. For the same reason, PgConnectOptions::options fails with unsupported startup parameter in options.
  • Prepared statements work with the default settings. sqlx frees statements that it evicts from its cache with a protocol-level Close message, which PgBouncer handles, also inside transactions. Don’t set statement_cache_capacity(0): it isn’t needed, and with the cache turned off, sqlx still prepares named statements but never closes them.
  • Don’t use .persistent(false) on queries that run outside a transaction. sqlx then prepares an unnamed statement and executes it in a second round trip, which PgBouncer can send to a different Postgres connection. The query can fail with unnamed prepared statement does not exist, or silently run another client’s statement and return wrong results.
  • Session settings made with SET, including in a pool’s after_connect hook, don’t stay on your connection, and can leak to other clients. Use SET LOCAL inside a transaction instead.
  • Migrations must use the direct connection. sqlx-cli and the query! macros can’t connect through PgBouncer, because they always send extra_float_digits. sqlx::migrate! takes a session-level advisory lock (pg_advisory_lock), and through PgBouncer the unlock can reach a different Postgres connection. The lock then stays held on a pooled connection, and later migrations, including over the direct connection, hang until PgBouncer closes that connection.

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 PgPool
  • Security: IP access lists and private networking
Last modified on October 1, 2026