Skip to main content
Django is a Python web framework with a built-in ORM and migration system, and it talks to PostgreSQL through the psycopg 3 driver. In this guide, you connect a new Django project to ClickHouse Managed Postgres through PgBouncer, create a 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 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 connects Django through the bundled PgBouncer on port 6432. 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 a todos app:
psycopg[binary] includes a recent libpq, so you don’t need a local PostgreSQL installation.
Adding to an existing app?Skip the commands above. In your project’s virtual environment, run pip install "psycopg[binary]", then continue with Configure the connection, which replaces the SQLite DATABASES setting. To copy your SQLite data:
  • Before you change DATABASES, run python manage.py dumpdata --natural-foreign --natural-primary -e contenttypes -e auth.permission -o data.json.
  • After you run python manage.py migrate, run python manage.py loaddata data.json.

Configure the connection

Move the CA certificate you downloaded into the project directory, next to manage.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:
Set the host and password as environment variables, so they stay out of your code:
Open mysite/settings.py, add import os below from pathlib import Path, and replace the DATABASES setting with:
mysite/settings.py
Django passes the keys in 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.
If your app reads a DATABASE_URL with dj-database-url, it passes sslmode and sslrootcert from the URL query string to OPTIONS. Call dj_database_url.config(conn_max_age=600, conn_health_checks=True, disable_server_side_cursors=True) to get the same settings. A relative sslrootcert path in the URL is resolved against the directory you start Django from.

Create a model and run migrations

Add the todos app to INSTALLED_APPS in mysite/settings.py:
mysite/settings.py
Define a Todo model in todos/models.py:
todos/models.py
Create a migration for the model and apply all migrations:
Besides 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

Replace todos/views.py with two views that create, list, update, and delete todos with the Django ORM:
todos/views.py
Replace mysite/urls.py to route /todos and /todos/<id> to the views:
mysite/urls.py

Run and verify

Start the development server:
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:3111/todos in your browser to list the remaining todos. The response looks like this:
To see the data in the console, open SQL console in the left sidebar of your service, expand 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.iterator reads rows through a server-side cursor in chunks. Outside a transaction, Django declares the cursor WITH HOLD and fetches each chunk separately, so through PgBouncer a fetch can land on a Postgres connection that doesn’t have the cursor and fails with cursor "_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 port 5432 with 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_AGE set to 600 cut 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 up max_connections. CONN_HEALTH_CHECKS makes 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": True in OPTIONS, which needs pip install "psycopg[binary,pool]") gives a similar speedup and also works through PgBouncer. It requires CONN_MAX_AGE set to 0 and keeps at least four connections open per process. With PgBouncer, CONN_MAX_AGE is enough for most apps; consider the pool for threaded or ASGI servers.
  • Prepared statements: no setting is needed. Django sets psycopg’s prepare_threshold to None and binds query parameters on the client by default, so it doesn’t create server-side prepared statements. If you turn on "server_side_binding": True and set "prepare_threshold" in OPTIONS, prepared statements also work through PgBouncer, because psycopg[binary] bundles libpq 18 and frees statements with a protocol-level Close message. With psycopg built against libpq 16 or older, freeing a statement inside a transaction fails with prepared statement "_pg3_0" does not exist. To check your version, run python -c "import psycopg; print(psycopg.pq.version())". You need 170000 or later.
  • Session state: a SET statement, such as SET 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 with unsupported startup parameter in options: search_path. Set defaults on the database instead, for example with ALTER DATABASE guide_django SET search_path = myschema, public;. Don’t use the assume_role option, which runs SET ROLE. Django’s own TIME_ZONE handling and the isolation_level option work through PgBouncer.
To connect directly instead, set 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

Last modified on October 1, 2026