> ## Documentation Index
> Fetch the complete documentation index at: https://private-7c7dfe99-detect-table-modification.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Use Django with ClickHouse Managed Postgres

> Connect a Django app to ClickHouse Managed Postgres through PgBouncer with psycopg 3 and verified TLS, run migrations, and serve a small JSON API

export const BetaBadge = ({link, galaxyTrack, galaxyEvent}) => {
  if (link) {
    return <a href={link} target="_blank" rel="noopener noreferrer" className="betaBadge" onClick={galaxyTrack && galaxyEvent ? galaxyOnClick(galaxyEvent) : undefined}>
                <span>Beta</span>
            </a>;
  }
  return <a href="https://clickhouse.com/docs/reference/settings/beta-and-experimental-features#beta-features" className="betaBadge">
            <span>Beta feature</span>
        </a>;
};

<BetaBadge link="https://clickhouse.com/cloud/postgres" galaxyTrack={true} galaxyEvent="docs.managed-postgres.guides-django-beta" />

[Django](https://www.djangoproject.com/) is a Python web framework with a built-in ORM and migration system, and it talks to PostgreSQL through the [psycopg 3](https://www.psycopg.org/psycopg3/) 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.

<h2 id="prerequisites">
  Prerequisites
</h2>

* 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`](https://www.postgresql.org/download/), to create the database. You can also run the `CREATE DATABASE` statement in the [SQL console](/integrations/connectors/sql-clients/sql-console).

<h2 id="create-service">
  Create a ClickHouse Managed Postgres service
</h2>

In the ClickHouse Cloud console, click **New service** and select **Postgres**. The instance is ready in a few minutes. See the [quickstart](/products/managed-postgres/quickstart) for a walkthrough.

<h2 id="connection-details">
  Get your connection details
</h2>

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](/products/managed-postgres/connection#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](#pgbouncer).

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.

<h2 id="project-setup">
  Set up the project
</h2>

Create a project directory with a virtual environment, install Django and psycopg 3, and create a Django project with a `todos` app:

```bash theme={null}
mkdir django-managed-postgres && cd django-managed-postgres
python3 -m venv .venv
source .venv/bin/activate
pip install django "psycopg[binary]"
django-admin startproject mysite .
python manage.py startapp todos
```

`psycopg[binary]` includes a recent `libpq`, so you don't need a local PostgreSQL installation.

<Tip>
  **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](#configure-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`.
</Tip>

<h2 id="configure-connection">
  Configure the connection
</h2>

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:

```bash theme={null}
psql "postgresql://postgres:<PASSWORD>@your-instance.pg.clickhouse.cloud:5432/postgres?sslmode=verify-full&sslrootcert=ca-certificate.pem" -c "CREATE DATABASE guide_django;"
```

```text theme={null}
CREATE DATABASE
```

Set the host and password as environment variables, so they stay out of your code:

```bash theme={null}
export DB_HOST="your-instance.pg.clickhouse.cloud"
export DB_PASSWORD='<PASSWORD>'
```

Open `mysite/settings.py`, add `import os` below `from pathlib import Path`, and replace the `DATABASES` setting with:

```python title="mysite/settings.py" theme={null}
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": os.environ["DB_HOST"],
        "PORT": "6432",
        "NAME": "guide_django",
        "USER": "postgres",
        "PASSWORD": os.environ["DB_PASSWORD"],
        # Reuse connections across requests instead of opening one per request
        "CONN_MAX_AGE": 600,
        "CONN_HEALTH_CHECKS": True,
        # Server-side cursors don't work with PgBouncer transaction pooling
        "DISABLE_SERVER_SIDE_CURSORS": True,
        "OPTIONS": {
            "sslmode": "verify-full",
            "sslrootcert": BASE_DIR / "ca-certificate.pem",
        },
    }
}
```

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.

<Note>
  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`.
</Note>

<Tip>
  If your app reads a `DATABASE_URL` with [`dj-database-url`](https://github.com/jazzband/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.
</Tip>

<h2 id="migrations">
  Create a model and run migrations
</h2>

Add the `todos` app to `INSTALLED_APPS` in `mysite/settings.py`:

```python title="mysite/settings.py" theme={null}
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'todos',
]
```

Define a `Todo` model in `todos/models.py`:

```python title="todos/models.py" theme={null}
from django.db import models


class Todo(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
```

Create a migration for the model and apply all migrations:

```bash theme={null}
python manage.py makemigrations todos
python manage.py migrate
```

```text theme={null}
Migrations for 'todos':
  todos/migrations/0001_initial.py
    + Create model Todo
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, todos
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK
  Applying todos.0001_initial... OK
```

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.

<h2 id="query">
  Add a JSON endpoint
</h2>

Replace `todos/views.py` with two views that create, list, update, and delete todos with the Django ORM:

```python title="todos/views.py" theme={null}
import json

from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt

from .models import Todo

FIELDS = ["id", "title", "done", "created_at"]


def as_dict(todo):
    return {field: getattr(todo, field) for field in FIELDS}


# This example API skips CSRF checks; add authentication before you deploy it
@csrf_exempt
def todo_list(request):
    if request.method == "POST":
        # Create
        todo = Todo.objects.create(title=json.loads(request.body)["title"])
        return JsonResponse(as_dict(todo), status=201)
    # Read
    return JsonResponse(list(Todo.objects.order_by("id").values(*FIELDS)), safe=False)


@csrf_exempt
def todo_detail(request, pk):
    todo = get_object_or_404(Todo, pk=pk)
    if request.method == "PATCH":
        # Update: toggle the done flag
        todo.done = not todo.done
        todo.save(update_fields=["done"])
    elif request.method == "DELETE":
        # Delete
        data = as_dict(todo)
        todo.delete()
        return JsonResponse(data)
    return JsonResponse(as_dict(todo))
```

Replace `mysite/urls.py` to route `/todos` and `/todos/<id>` to the views:

```python title="mysite/urls.py" theme={null}
from django.contrib import admin
from django.urls import path

from todos import views

urlpatterns = [
    path("admin/", admin.site.urls),
    path("todos", views.todo_list),
    path("todos/<int:pk>", views.todo_detail),
]
```

<h2 id="verify">
  Run and verify
</h2>

Start the development server:

```bash theme={null}
python manage.py runserver 3111
```

```text theme={null}
Performing system checks...

System check identified no issues (0 silenced).
October 01, 2026 - 14:39:10
Django version 6.1.1, using settings 'mysite.settings'
Starting WSGI development server at http://127.0.0.1:3111/
Quit the server with CONTROL-C.
```

In a second terminal, create two todos, mark the first one done, and delete the second:

```bash theme={null}
curl -X POST http://localhost:3111/todos -d '{"title": "Try Django"}'
curl -X POST http://localhost:3111/todos -d '{"title": "Write a migration"}'
curl -X PATCH http://localhost:3111/todos/1
curl -X DELETE http://localhost:3111/todos/2
```

The `PATCH` request returns the updated row:

```json theme={null}
{"id": 1, "title": "Try Django", "done": true, "created_at": "2026-10-01T14:38:51.036Z"}
```

Open `http://localhost:3111/todos` in your browser to list the remaining todos. The response looks like this:

```json theme={null}
[{"id": 1, "title": "Try Django", "done": true, "created_at": "2026-10-01T14:38:51.036Z"}]
```

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:

| id | title | done | created\_at |
| - | - | - | - |
| 1 | Try Django | true | 2026-10-01 14:38:51.036352+00 |

<h2 id="pgbouncer">
  PgBouncer settings for Django
</h2>

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`.

<h2 id="next-steps">
  Next steps
</h2>

* [Connection](/products/managed-postgres/connection): connection strings, PgBouncer, and TLS
* [Settings](/products/managed-postgres/settings): Postgres and PgBouncer parameters, such as `max_connections`
* [Read replicas](/products/managed-postgres/read-replicas): send reads to a replica with Django's [multiple databases](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) support
* [Security](/products/managed-postgres/security): IP access lists and private networking
