Tutorial

Django PostgreSQL Setup: psycopg 3, Docker and Migrations

Set up Django with PostgreSQL using psycopg 3, Docker and env-based settings. Covers migrating off SQLite, production settings and which setup I'd pick.

Key takeaways

  • Run Postgres in Docker so development matches production. Create a dedicated owner role and database for the app, and never connect Django as the postgres superuser.
  • Install psycopg 3 with the binary and pool extras. Django's docs recommend it, and the built-in pool works only with psycopg.
  • Moving off SQLite: dump with dumpdata excluding contenttypes and permissions, run migrate on the empty database, loaddata, then reset sequences. Watch for type looseness, nulls in NOT NULL columns, and sequence collisions.

Use PostgreSQL with the psycopg 3 driver and the django.db.backends.postgresql engine, and read every connection setting from environment variables. We pick it over SQLite because PostgreSQL handles concurrent writes safely and supports row-level locking.

The steps below take you from an empty machine to a working Django app on Postgres. They cover a Docker database, a dedicated role, the driver, settings, migrations and moving old SQLite data. If you are still choosing a framework, read our Flask or Django pick for each kind of project first.

Django on PostgreSQL: my short answer

My recommendation is PostgreSQL with psycopg 3, configured through environment variables. Django ships with SQLite by default, which is fine for development but breaks under concurrent writes. PostgreSQL's row-level locking is what makes it suitable for a SaaS with real users.

Even for a small project I would start on Postgres. Switching later means moving data, and the differences between the two databases show up at the worst time.

What you need first

You need a current Python, a supported Django, PostgreSQL 15 or higher, and a shell. Check each item before you start.

Step 1: Run PostgreSQL locally with Docker

Run Postgres in Docker, because it gives you the same server version in development as in production. A system install drifts, and removing it is messy. A named volume keeps your data between container restarts.

Save this as docker-compose.yml in your project root. The volume path matters: for PostgreSQL 18 and later the mount path is /var/lib/postgresql, and for 17 and earlier it is /var/lib/postgresql/data.

services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD}
      POSTGRES_DB: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql

volumes:
  pgdata:

Start it with the command below. Set POSTGRES_ADMIN_PASSWORD in your shell first so no password sits in the file.

export POSTGRES_ADMIN_PASSWORD='change-me'
docker compose up -d

Step 2: Create the database and a dedicated role

Create a role for the app and a database it owns, and never connect Django as the postgres superuser. A superuser can drop databases, create roles and read everything, so one leaked credential or one bad migration costs far more. A plain owner role limits the damage to a single database.

Open a shell in the container with docker compose exec postgres psql -U postgres, then run this SQL.

CREATE ROLE myapp WITH LOGIN PASSWORD 'a-long-random-password';
CREATE DATABASE myapp OWNER myapp;

Step 3: Install the psycopg 3 driver

Install psycopg 3 with the binary and pool extras. The project's quick install is the command below.

pip install "psycopg[binary,pool]"

Django requires psycopg 3.1.12+ or psycopg2 2.9.9+, and its documentation recommends psycopg. The same page says psycopg2 support will go away eventually, so for a new project the choice is easy.

psycopg 3 psycopg2
Django support status Recommended; 3.1.12+ required Supported at 2.9.9+, likely to be deprecated and removed
Install command pip install "psycopg[binary,pool]" pip install psycopg2-binary
Pool support Yes, through psycopg[pool] No Django built-in pool
Pick it when New projects and anything you will maintain Only when a legacy dependency forces it

The pure-Python package depends at runtime on libpq, the PostgreSQL client library. The binary extra avoids installing that yourself.

Step 4: Point DATABASES at PostgreSQL

To set PostgreSQL as the database in Django, set ENGINE to django.db.backends.postgresql and fill in NAME, USER, PASSWORD, HOST and PORT. Our full-stack tutorial reads these from environment variables, and so should you. Hard-coding values like the ones in a typical Stack Overflow question works locally but puts secrets in the repo.

Put this in settings.py. Defaults are fine for the host and port, but leave the password without one so a missing variable fails loudly.

import os

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.getenv("POSTGRES_HOST", "localhost"),
        "PORT": os.getenv("POSTGRES_PORT", "5432"),
    }
}

If you prefer a single DATABASE_URL, the optional variant below uses the third-party dj-database-url package, installed with pip install dj-database-url.

import dj_database_url

DATABASES = {
    "default": dj_database_url.config(
        env="DATABASE_URL",
        conn_max_age=60,
    )
}

Export the variables from a .env file that is listed in .gitignore, and commit only a .env.example with placeholder values.

Step 5: Run migrations and confirm the connection

Run migrate, open dbshell and create a superuser, in that order. Django records schema state in migration files and the django_migrations table, so a fresh database gets every migration applied.

  1. Load your environment variables in the shell.
  2. Run python manage.py migrate. If it finishes without errors, Django is connecting to Postgres.
  3. Run python manage.py dbshell to open psql with your settings.
  4. In psql, run \dt to confirm that tables exist.
  5. Run python manage.py createsuperuser and follow the prompts.
python manage.py migrate
python manage.py dbshell
# inside psql:
\dt
SELECT app, name FROM django_migrations ORDER BY id DESC LIMIT 5;
\q
python manage.py createsuperuser

Step 6: Move existing data off SQLite (if you have any)

Dump from SQLite with dumpdata, excluding contenttypes and permissions, then loaddata into Postgres. Those two tables are rebuilt by migrate, and their IDs can differ between databases. One Django ticket reporter described how content-type records created with a different id changed the permissions of his Reporters group.

  1. With settings still pointing at SQLite, run the dump.
  2. Switch settings to Postgres and run migrate on the empty database.
  3. Load the fixture.
  4. Reset the sequences.
python manage.py dumpdata \
  --natural-foreign --natural-primary \
  --exclude contenttypes --exclude auth.permission \
  --indent 2 -o data.json

# switch DATABASES to PostgreSQL, then:
python manage.py migrate
python manage.py loaddata data.json
python manage.py sqlsequencereset myapp | python manage.py dbshell

Watch for three pitfalls. SQLite is loose about types, so a text value in an integer column can load there and fail in Postgres. Nulls in columns that are now NOT NULL need a default or a cleanup first. And sequences are not advanced by loaddata with explicit IDs, so the next insert collides unless you run sqlsequencereset, as above.

If your fixtures do depend on content types, Django's Russell Keith-Magee advised that you serialize the contenttypes table so the IDs are guaranteed.

Settings I change before production

Before production I change five things: connection reuse, SSL, pooling, network placement and secret storage. Each is a few lines.

Connection reuse and SSL

Set CONN_MAX_AGE so Django reuses connections, and CONN_HEALTH_CHECKS so a stale one is replaced. Add sslmode in OPTIONS to force encryption.

DATABASES["default"].update({
    "CONN_MAX_AGE": 60,
    "CONN_HEALTH_CHECKS": True,
    "OPTIONS": {"sslmode": "require"},
})

Built-in pool or PgBouncer

Django's own pool is supported only with psycopg and needs psycopg[pool]. With it on, CONN_MAX_AGE must be 0 and lifetime is set with max_lifetime. Use the built-in pool for one app; use PgBouncer when many processes share the database.

DATABASES["default"]["CONN_MAX_AGE"] = 0
DATABASES["default"]["OPTIONS"] = {
    "sslmode": "require",
    "pool": {"min_size": 2, "max_size": 10, "max_lifetime": 3600},
}

Behind PgBouncer in transaction mode, Django advises disabling server-side cursors. Our PgBouncer in Docker guide covers the setup.

DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True

Private network and secrets

Put a managed database on a private network. In Microsoft's Azure tutorial, the flexible server is accessible only from within the virtual network. That tutorial also advises keeping secrets out of App Service and moving them to a key vault. In Django, nothing changes except where the environment variable comes from.

# App setting resolved by the platform from a vault, not stored in the repo
# POSTGRES_PASSWORD=@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/db-password/)
PASSWORD = os.environ["POSTGRES_PASSWORD"]

Use the PostgreSQL features you switched for

Use django.contrib.postgres for arrays, key-value data and ranges. These fields live in django.contrib.postgres.fields, and django.contrib.postgres must be in INSTALLED_APPS for HStoreField.

For HStoreField you also need the extension, which Django's CreateExtension operation adds in a migration. Here is an example model with all three field types.

from django.contrib.postgres.fields import ArrayField, HStoreField, DateTimeRangeField
from django.contrib.postgres.indexes import GinIndex, GistIndex
from django.db import models

class Event(models.Model):
    name = models.CharField(max_length=200)
    tags = ArrayField(models.CharField(max_length=30), default=list)
    attrs = HStoreField(default=dict)
    window = DateTimeRangeField()

    class Meta:
        indexes = [
            GinIndex(fields=["tags"]),
            GistIndex(fields=["window"]),
        ]

Note default=list: Django warns that default=[] creates a mutable default shared between all instances. On indexes, the docs say GiST may suit range fields and HStoreField, and GIN may help ArrayField. Two cautions: PostgreSQL does not enforce ArrayField.size, and unrecognized HStoreField lookups are treated as key lookups without errors.

Check that a query uses your index with explain(). A sequential scan on a large table means the index is missing or ignored. For slow pages caused by repeated queries, see our N+1 query guide.

print(Event.objects.filter(tags__contains=["urgent"]).explain(analyze=True))

When Django shouldn't be the only thing touching the schema

When FastAPI shares the database, give every table exactly one migration owner. Django and FastAPI can share a PostgreSQL schema, but migrations need clear ownership rules. Our full walkthrough is in the shared-schema migration guide.

  1. Assign each table to Django or Alembic, never both. A sensible split gives Django auth, sessions, admin and app tables, and Alembic event logs and async job results.
  2. Keep Django-managed tables, including django_migrations, out of Alembic's target metadata, using a separate metadata object.
  3. In deploys, run Django migrations first and Alembic second, never at the same time, because simultaneous runs cause lock contention on ALTER TABLE.
  4. Avoid sync ORM calls in async handlers: Django's ORM is synchronous, so calling it inside a FastAPI async def handler blocks the event loop. Wrap calls with sync_to_async or use a plain def handler.

For the wider setup, see our Django, FastAPI and Next.js build. If you also separate tenants in the database, read our multi-tenant isolation trade-offs.

What you end up with and how to check it worked

You end up with a Django app on PostgreSQL, configured from environment variables, with a dedicated role and a driver Django recommends. Run this checklist to confirm it.

  • python manage.py migrate reports no migrations to apply, and python manage.py makemigrations --check finds no changes.
  • You can log in at /admin with the superuser you created.
  • \dt in psql lists the Django tables and django_migrations.
  • In production, the connection uses SSL: run SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid(); and expect t.
python manage.py migrate --check
python manage.py makemigrations --check --dry-run
python manage.py dbshell -- -c "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();"

Frequently asked questions

Should I use psycopg or psycopg2 with Django?

Use psycopg 3. Django's documentation recommends it, and the built-in connection pool works only with psycopg. Expect it to go away over time.

Which PostgreSQL versions does Django support?

The Django documentation says it supports PostgreSQL 15 and higher. Check the databases page for your Django release before you pick a server version.

Can I use SQLite in development and PostgreSQL in production?

You can, but I don't. SQLite breaks under concurrent writes, and differences in typing and locking let bugs pass in development and fail in production. Run Postgres in Docker locally instead.

Do I need to recreate migrations after switching from SQLite to PostgreSQL?

No. Keep your existing migration files and run migrate against the empty Postgres database, then load your data. Django tracks applied migrations in the django_migrations table of the new database.

Which setup to use: my decision matrix

Use SQLite only for throwaway prototypes, Docker Postgres for development, and managed Postgres for production. Add PgBouncer when connection counts grow.

Setup Use case Concurrency Ops effort My pick
SQLite Prototypes Poor under concurrent writes None Only for throwaway work
Docker Postgres Local development and CI Good Low Yes, for development
Managed Postgres Production Good Low Yes, for production
Postgres + PgBouncer Many app processes or workers Best for high connection counts Medium Add it when connections become the limit

Sources

  1. Django 6.0 Update - blocked by Trac · Issue #285 · django/code.djangoproject.com, github.com
  2. Databases | Django documentation | Django, docs.djangoproject.com
  3. GitHub - mutablelogic/docker-postgres: PostgreSQL image with primary & replica support · GitHub, github.com
  4. GitHub - psycopg/psycopg: New generation PostgreSQL database adapter for the Python programming language · GitHub, github.com
  5. database - How to properly connect PostgreSQL with Django? - Stack Overflow, stackoverflow.com
  6. #5253 (serializer for csv format for loaddata and dumpdata) – Django, code.djangoproject.com
  7. Tutorial: Deploy a Python Django web app with PostgreSQL - Azure App Service | Microsoft Learn, learn.microsoft.com
  8. PostgreSQL specific model fields | Django documentation | Django, docs.djangoproject.com