Skip to main content
pgx is the most widely used PostgreSQL driver for Go, and pgxpool is its connection pool. In this guide, you connect a Go service to ClickHouse Managed Postgres over verified TLS, create a todos table with a golang-migrate migration, and serve create, read, update, and delete requests from a net/http server.

Prerequisites

  • Go 1.25 or later. This guide was tested with Go 1.27.1, pgx 5.11.0, and golang-migrate 4.20.1.
  • 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 uses port 5432 and ends in sslmode=verify-full&sslrootcert=<service-name>-ca-certificate.pem. Click Download CA certificate. You can also download it later from Settings → CA Certificate. The certificate is unique to your instance, so pgx can use it to verify that it’s talking to your server. This guide connects directly to Postgres on port 5432, both for migrations and for the app. A Go service is a long-lived process, and pgxpool already keeps a pool of connections in it, so it doesn’t need a second pooler. If you run many instances of the service and need more client connections than Postgres allows, use the bundled PgBouncer (via PgBouncer, port 6432) for the app; see Use PgBouncer, which needs one extra connection setting.

Set up the project

Create a Go module, add pgx, and install the migrate CLI with the pgx driver:
The -tags 'pgx5' flag compiles the migrate CLI with its pgx v5 driver, which uses the pgx5:// URL scheme. go install puts the binary in $GOBIN, or in $(go env GOPATH)/bin if GOBIN isn’t set, so make sure that directory is on your PATH.
Adding to an existing app?Skip mkdir and go mod init. In your module, run go get github.com/jackc/pgx/v5 and the go install command above, then continue with Configure the connection. If your code uses database/sql, stdlib.OpenDBFromPool wraps the same pool as a *sql.DB, so you can move existing queries over gradually; change ? placeholders to $1, $2, and so on.

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:
Set two environment variables in your shell. They point at the same guide_go database and differ only in the URL scheme: the app reads DATABASE_URL, and the migrate CLI uses MIGRATE_URL:
pgx parses the URL like libpq does. With sslmode=verify-full, it loads the CA file from sslrootcert and checks both the certificate chain and the hostname, so you don’t need any TLS code. The sslrootcert path is resolved relative to the directory you run commands from. If your password contains special characters such as @, /, or #, URL-encode them.
Always set sslmode=verify-fullWithout sslmode, pgx uses prefer, which doesn’t verify the server certificate, even if you set sslrootcert. With sslmode=verify-full and a wrong CA file, pgx fails with x509: certificate signed by unknown authority, and if the file doesn’t exist, it fails with unable to read CA file. If you connect by IP address instead of the hostname, it fails with x509: cannot validate certificate for <IP> because it doesn't contain any IP SANs.

Create the schema with a migration

Create the migration files:
Fill in the up migration:
migrations/000001_create_todos.up.sql
And the down migration, which reverts it:
migrations/000001_create_todos.down.sql
Apply the migration:
migrate records the current version in the schema_migrations table and only applies newer migrations on later runs. Commit the migrations directory with your code.
Always run migrate over the direct connection (port 5432). It holds a session-level advisory lock while it runs, so that two deployments can’t migrate at the same time. Through PgBouncer, the lock and unlock can run on different Postgres connections: the lock stays held, and later migrate runs hang or fail with timeout: can't acquire database lock.

Query the database

Create db.go. It creates the pool from DATABASE_URL and checks the connection at startup by asking Postgres which TLS version the session uses:
db.go
Create todos.go with the HTTP handlers. Each handler runs one parameterized query on the pool, and pgx.CollectRows and pgx.CollectExactlyOneRow scan the rows into the Todo struct by column name:
todos.go
Passing values as arguments ($1) rather than formatting them into the SQL string prevents SQL injection. pgx sends them separately from the query text. You don’t need to check the error from pool.Query separately: pgx.CollectRows and pgx.CollectExactlyOneRow return it. Create main.go. It creates one pool for the whole process, serves the routes on port 3114, and closes the pool on Ctrl+C. In an existing app, call newPool once at startup and registerTodoRoutes on your http.ServeMux instead:
main.go

Run and verify

Add the remaining dependencies of pgxpool to go.mod and go.sum, then start the server:
TLSv1.3 comes from pg_stat_ssl on the server, so it confirms that the session is encrypted. If you point sslrootcert at any other certificate, the server doesn’t start and logs x509: certificate signed by unknown authority, which shows that the certificate is actually checked. In a second terminal, create two todos, mark the first one done, and delete the second:
The PATCH request returns the updated row:
Open http://localhost:3114/todos in your browser to list the remaining todos. pgx returns timestamptz values in your local time zone. The response looks like this:
Stop the server with Ctrl+C. It logs Shutting down and closes the pool. To see the data in the console, open SQL console in the left sidebar of your service, expand guide_go and then public, and click the todos table. The schema_migrations table next to it holds the migration version. The todos table contains the remaining row:

Use PgBouncer

To send the app’s queries through the bundled PgBouncer, select via PgBouncer in the Connect modal, change the port in DATABASE_URL to 6432, and add default_query_exec_mode=cache_describe. Keep MIGRATE_URL on port 5432:
pgx reads default_query_exec_mode from the URL and doesn’t send it to the server. No code changes are needed, and the same CA certificate works. The startup log now shows TLS: none, because pg_stat_ssl reports PgBouncer’s own connection to Postgres. Your app’s connection to PgBouncer is still verified: a wrong CA certificate fails in the same way.
Don’t use pgx’s default query mode through PgBouncerBy default, pgx prepares each query as a named statement once per connection and reuses the column types it got back. PgBouncer prepares these statements on whichever Postgres connection runs the query and shares them between clients. After a migration changes a column’s type, or adds a column to a table queried with SELECT *, queries through PgBouncer can keep failing with cached plan must not change result type, even after you restart the app, or, with pgx 5.9 and later, return wrong values without an error. For example, a bigint column changed to double precision comes back as the raw bits of the float64 value, such as 4631178160564600832 for 42.5.cache_describe caches only the parameter and column types, runs every query as an unnamed statement in one round trip, and decodes rows using the column types that the server returns with each result, so it isn’t affected. exec also works. Don’t use describe_exec through PgBouncer: it splits each query across two round trips, which PgBouncer can send to different Postgres connections. You don’t need statement_cache_capacity=0.
PgBouncer runs in transaction pooling mode, so each transaction can run on a different Postgres connection:
  • Transactions with pool.Begin or pgx.BeginFunc work as usual.
  • Session settings made with SET don’t stay with your connection, and can leak to other clients. Use SET LOCAL inside a transaction instead. PgBouncer also rejects search_path in the connection URL with unsupported startup parameter: search_path; schema-qualify table names instead.
  • SQL-level PREPARE and EXECUTE, LISTEN, and session advisory locks don’t work.

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