todos table with a migration, and serve create, read, update, and delete requests from a small JSON view. Every connection uses TLS with full certificate verification.
Prerequisites
- Python 3.12 or later, as required by Django 6. This guide was tested with Python 3.13, Django 6.1.1, and psycopg 3.3.6.
- A ClickHouse Cloud account
psql, to create the database. You can also run theCREATE DATABASEstatement 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 connects Django through the bundled PgBouncer on port6432. A production Django app usually runs several worker processes on several machines, and each one opens its own connections. PgBouncer runs in transaction pooling mode and lets all of them share a small number of Postgres connections. Django needs a few settings for transaction pooling, which this guide includes and explains in PgBouncer settings for Django.
Select via PgBouncer to see the pooled connection details. The port changes to 6432, and the connection string includes sslmode=verify-full.
In the modal, 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.
Set up the project
Create a project directory with a virtual environment, install Django and psycopg 3, and create a Django project with atodos app:
psycopg[binary] includes a recent libpq, so you don’t need a local PostgreSQL installation.
Configure the connection
Move the CA certificate you downloaded into the project directory, next tomanage.py, 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:
mysite/settings.py, add import os below from pathlib import Path, and replace the DATABASES setting with:
mysite/settings.py
OPTIONS to psycopg, which hands them to libpq. sslmode set to verify-full makes libpq check that the server certificate is signed by your service’s CA and matches the hostname. BASE_DIR makes the certificate path absolute, so the setting works no matter which directory you start Django from.
If
sslrootcert points to the wrong CA, the connection fails with SSL error: certificate verify failed. Without sslrootcert, it fails with root certificate file ".../.postgresql/root.crt" does not exist, because libpq falls back to its default CA location. If you connect by IP address instead of the hostname, it fails with server certificate for "..." does not match host name.Create a model and run migrations
Add thetodos app to INSTALLED_APPS in mysite/settings.py:
mysite/settings.py
Todo model in todos/models.py:
todos/models.py
todos_todo, migrate creates the tables for Django’s built-in apps, such as auth_user and django_session, and records applied migrations in django_migrations. In an existing app, skip the todos model and run only python manage.py migrate, which creates all of your app’s tables in the new database.
migrate doesn’t take a session-level advisory lock, so it works through PgBouncer. It also doesn’t prevent concurrent runs, so run migrations from a single place, such as one deploy step.
Add a JSON endpoint
Replacetodos/views.py with two views that create, list, update, and delete todos with the Django ORM:
todos/views.py
mysite/urls.py to route /todos and /todos/<id> to the views:
mysite/urls.py
Run and verify
Start the development server:PATCH request returns the updated row:
http://localhost:3111/todos in your browser to list the remaining todos. The response looks like this:
guide_django and then public, and click the todos_todo table. The table contains the remaining row:
PgBouncer settings for Django
PgBouncer runs in transaction pooling mode: each transaction, or each statement outside a transaction, can run on a different Postgres connection. Here’s how that affects the settings above and other Django features:DISABLE_SERVER_SIDE_CURSORS:QuerySet.iteratorreads rows through a server-side cursor in chunks. Outside a transaction, Django declares the cursorWITH HOLDand fetches each chunk separately, so through PgBouncer a fetch can land on a Postgres connection that doesn’t have the cursor and fails withcursor "_django_curs_..." does not exist. This only happens when other clients use the pool at the same time, so it often doesn’t show up in development. With the setting on,.iterator()uses a regular cursor, and psycopg loads the whole result into memory. To stream a very large table, run that code over the direct connection on port5432with the setting off.CONN_MAX_AGE: by default (0), Django opens a new connection for every request. A TLS connection takes several network round trips to open, through PgBouncer as well as directly, so reusing connections makes requests much faster. In a test from a laptop with four Gunicorn workers,CONN_MAX_AGEset to600cut the median request time from about 670 ms to 95 ms. Through PgBouncer, an open Django connection doesn’t hold a Postgres connection between transactions, so persistent connections don’t use upmax_connections.CONN_HEALTH_CHECKSmakes Django check a reused connection at the start of each request and reconnect if it was closed.- Connection pool: Django’s built-in pool (
"pool": TrueinOPTIONS, which needspip install "psycopg[binary,pool]") gives a similar speedup and also works through PgBouncer. It requiresCONN_MAX_AGEset to0and keeps at least four connections open per process. With PgBouncer,CONN_MAX_AGEis enough for most apps; consider the pool for threaded or ASGI servers. - Prepared statements: no setting is needed. Django sets psycopg’s
prepare_thresholdtoNoneand binds query parameters on the client by default, so it doesn’t create server-side prepared statements. If you turn on"server_side_binding": Trueand set"prepare_threshold"inOPTIONS, prepared statements also work through PgBouncer, becausepsycopg[binary]bundleslibpq18 and frees statements with a protocol-levelClosemessage. With psycopg built againstlibpq16 or older, freeing a statement inside a transaction fails withprepared statement "_pg3_0" does not exist. To check your version, runpython -c "import psycopg; print(psycopg.pq.version())". You need170000or later. - Session state: a
SETstatement, such asSET search_path, only applies to the Postgres connection that ran it, so later queries may not see it, and other clients may. PgBouncer rejects Django’s"options": "-c search_path=..."setting withunsupported startup parameter in options: search_path. Set defaults on the database instead, for example withALTER DATABASE guide_django SET search_path = myschema, public;. Don’t use theassume_roleoption, which runsSET ROLE. Django’s ownTIME_ZONEhandling and theisolation_leveloption work through PgBouncer.
PORT to 5432 and remove DISABLE_SERVER_SIDE_CURSORS. Each Django worker then holds its own Postgres connection, so keep the total number of workers below max_connections.
Next steps
- Connection: connection strings, PgBouncer, and TLS
- Settings: Postgres and PgBouncer parameters, such as
max_connections - Read replicas: send reads to a replica with Django’s multiple databases support
- Security: IP access lists and private networking