Create the project with uv init, uv add django psycopg2-binary, and django-admin startproject config . Create a PostgreSQL database and user, then in config/settings.py set DATABASES with ENGINE django.db.backends.postgresql, NAME, USER, PASSWORD, HOST, PORT (use os.environ for credentials). Run uv run python manage.py migrate and verify with manage.py dbshell. For production, use CONN_MAX_AGE or PgBouncer and SSL in OPTIONS.
Django ships with SQLite out of the box. When you need real concurrency or are ready to deploy, you switch to DBs like PostgreSQL. This guide walks through creating a Django project from scratch and wiring it to PostgreSQL: project layout, database and user creation, and a detailed look at settings.py so you know exactly what each option does and where to put it.
TL;DR: Create a Django project with uv, add psycopg2-binary, create a database and user in PostgreSQL, then set
DATABASESinsettings.py(ENGINE, NAME, USER, PASSWORD, HOST, PORT). Runmigrateand you have a working Django PostgreSQL setup.
Table of Contents
- What You Need
- Step 1: Create the Project with uv and Django
- Step 2: Create the PostgreSQL Database and User
- Step 3: Configure Django settings.py
- Step 4: Run Migrations and Verify
- Optional: First App and a Model
- Production Configuration
- Avoiding N+1 Queries
- Summary
What You Need
You need Python 3.10+, uv (install with curl -LsSf https://astral.sh/uv/install.sh | sh), and PostgreSQL installed and running.
- macOS:
brew install postgresql@16thenbrew services start postgresql@16 - Ubuntu/Debian:
sudo apt install postgresql postgresql-contribthensudo systemctl start postgresql - Docker:
docker run -d --name postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16. From Django useHOST=localhost; from another container usehost.docker.internal. Default port is 5432.
If you are new to PostgreSQL, the PostgreSQL Cheat Sheet has the commands for creating databases and users. Bookmark it; you will use it when you tune queries or debug connection issues.
Django does not talk to PostgreSQL directly. It uses the psycopg2 driver. You set the connection in settings.py; Django and psycopg2 do the rest.
flowchart LR
subgraph App["Django"]
ORM[fa:fa-code ORM]
Settings[fa:fa-cog settings.py]
end
subgraph Driver["Driver"]
Psyco[fa:fa-plug psycopg2]
end
subgraph DB["Database"]
PG[(fa:fa-database PostgreSQL)]
end
ORM --> Settings
Settings --> Psyco
Psyco -->|TCP 5432| PG
style ORM fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Settings fill:#fff3e0,stroke:#e65100,stroke-width:2px
style Psyco fill:#e0f2f1,stroke:#00695c,stroke-width:2px
style PG fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
Request hits Django. Django reads settings.py, opens a connection via psycopg2, and talks to PostgreSQL on port 5432. That is the whole chain.
Step 1: Create the Project with uv and Django
Create a new directory and a Python project with uv. uv creates a virtual environment and a pyproject.toml for you.
1
2
mkdir myapp && cd myapp
uv init
Add Django and the PostgreSQL adapter. You need both: Django is the framework, psycopg2 is the driver that talks to PostgreSQL.
1
uv add django psycopg2-binary
The -binary variant ships with compiled PostgreSQL client libraries, so you do not need libpq-dev on your machine. On a Linux server some teams use uv add psycopg2 (no binary) and install libpq-dev so the driver links to the system client. Both work with Django.
Create the Django project. The trailing dot creates the project in the current directory so you get a flat layout with manage.py at the root.
1
uv run django-admin startproject config .
This creates:
manage.py– entry point for Django commands.config/– the project package, with__init__.py,settings.py,urls.py,asgi.py,wsgi.py.
Your layout looks like this:
1
2
3
4
5
6
7
8
9
myapp/
manage.py
pyproject.toml
config/
__init__.py
settings.py <-- database config goes here
urls.py
asgi.py
wsgi.py
Django loads settings from the module in DJANGO_SETTINGS_MODULE. When you run uv run python manage.py runserver, that defaults to config.settings. The file you edit is config/settings.py.
Step 2: Create the PostgreSQL Database and User
One thing that trips people up: Django does not create the database. You create it once in PostgreSQL and give Django the name and credentials. The PostgreSQL Cheat Sheet has the full command set; below is the minimum you need.
Connect to PostgreSQL as the superuser. On macOS with Homebrew PostgreSQL you often have a user with your OS username; on Linux the default superuser is usually postgres.
1
2
3
4
5
# macOS (Homebrew): often your login user can connect
psql -d postgres
# Linux: use the postgres system user
sudo -u postgres psql
In the psql prompt, create a dedicated user and database for your app. Using a dedicated user (instead of the superuser) limits what the app can do and is what you want in production.
1
2
3
4
5
6
7
8
9
CREATE USER myapp WITH PASSWORD 'choose_a_strong_password';
CREATE DATABASE myapp_db
OWNER myapp
ENCODING 'UTF8';
GRANT ALL PRIVILEGES ON DATABASE myapp_db TO myapp;
\c myapp_db
GRANT ALL ON SCHEMA public TO myapp;
- CREATE USER – creates a role that can log in. Django will use this user.
- CREATE DATABASE … OWNER myapp – creates the database and makes
myappits owner so it can create tables and extensions. - GRANT ALL PRIVILEGES ON DATABASE – allows the user to connect and use the database.
- GRANT ALL ON SCHEMA public – in PostgreSQL 15+, the
publicschema is not granted by default; this lets the user create tables inpublic.
Exit psql with \q. Test the new user and database from the shell:
1
psql -U myapp -d myapp_db -h localhost -c '\conninfo'
You should see connection info for myapp_db as user myapp. If that works, Django will be able to connect with the same credentials.
Step 3: Configure Django settings.py
Open config/settings.py. Out of the box you get SQLite. It looks like this:
1
2
3
4
5
6
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
Replace it with a PostgreSQL configuration. The minimum that gets you connected is the next block: ENGINE, NAME, USER, PASSWORD, HOST, PORT. You can skip to Step 4 after that and come back for OPTIONS and timeouts when you need them.
The DATABASES setting, key by key
1
2
3
4
5
6
7
8
9
10
11
12
import os
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'myapp_db'),
'USER': os.environ.get('DB_USER', 'myapp'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
}
}
-
ENGINE – Must be
'django.db.backends.postgresql'. Django uses this to load the right backend and to call psycopg2. No other value will use PostgreSQL. -
NAME – The name of the database. It must already exist (you created it in Step 2). Not a path, unlike SQLite.
-
USER – The PostgreSQL role name. This is the user you created with
CREATE USER. -
PASSWORD – The password for that user. Never commit this to version control. Use environment variables and, for local dev, a
.envfile or export in your shell. -
HOST – Where PostgreSQL is running. Use
'localhost'for TCP on the same machine. Use''(empty string) to connect via Unix socket (common when PostgreSQL and the app are on the same host and you do not specify a host). In production this is often a hostname or an IP. -
PORT – TCP port. Default is
5432. Omit it or set to''when using a Unix socket.
For local development you can set defaults in os.environ.get('DB_NAME', 'myapp_db') and set the variables only in production. Or use a .env file and load it at the top of settings.py (e.g. with python-dotenv) so that os.environ is populated when Django starts.
OPTIONS for PostgreSQL
For local dev the block above is enough. When you need connection timeouts, SSL, keepalives, or session options (e.g. for production), add OPTIONS. Anything you pass in OPTIONS is forwarded to psycopg2.connect() (and thus to PostgreSQL’s libpq). Below is an example; the table that follows lists every option.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'myapp_db'),
'USER': os.environ.get('DB_USER', 'myapp'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
'OPTIONS': {
'connect_timeout': 10,
'keepalives': 1,
'keepalives_idle': 30,
'keepalives_interval': 10,
'keepalives_count': 5,
'options': '-c timezone=UTC -c statement_timeout=30000',
'application_name': 'myapp_django',
'sslmode': 'require',
},
}
}
Option reference (all passed via OPTIONS):
| Option | Meaning |
|---|---|
| connect_timeout | Seconds to wait when establishing the connection. 0 or omitted = wait indefinitely. |
| tcp_user_timeout | Milliseconds before unacknowledged data causes the connection to be closed. 0 = system default. |
| keepalives | 1 = enable TCP keepalives (default), 0 = disable. Reduces risk of silent drops by firewalls or load balancers. |
| keepalives_idle | Seconds of inactivity before the first keepalive is sent. 0 = system default. |
| keepalives_interval | Seconds between keepalive retransmits if the server does not respond. 0 = system default. |
| keepalives_count | Number of keepalives that can be lost before the connection is considered dead. 0 = system default. |
| options | Server command-line options at connection start, e.g. -c timezone=UTC or -c statement_timeout=60000. See PostgreSQL runtime config. |
| application_name | Label for this connection in pg_stat_activity and in server logs. Helps with debugging and connection tracking. |
| fallback_application_name | Used if application_name is not set (e.g. by a generic pooler). |
| sslmode | disable = no SSL; allow = try non-SSL then SSL; prefer = try SSL first (default); require = SSL required; verify-ca = verify server cert against CA; verify-full = verify cert and hostname. |
| sslrootcert | Path to CA certificate file, or system to use the system CA store. Needed for verify-ca / verify-full. |
| sslcert | Path to client certificate file (when the server requires client certs). |
| sslkey | Path to client private key file. |
| sslpassword | Passphrase for an encrypted client key (no env var equivalent; avoid putting in code; use a secret manager). |
| target_session_attrs | With multiple hosts: any = any server; read-write = primary only; read-only = replica only; primary = not in standby; standby = replica; prefer-standby = try standby first then any. |
| client_encoding | Client encoding for this connection, e.g. UTF8 or auto (from locale). |
For managed PostgreSQL (AWS RDS, Render, etc.) you typically set sslmode to require at minimum; some providers require verify-full and a path to their CA certificate in sslrootcert. Check your provider’s documentation.
Query timeout and retries
Query (statement) timeout – Django does not define a query timeout itself. You set it per connection using PostgreSQL session parameters in OPTIONS['options']. The one that matters for “cancel long-running queries” is statement_timeout (time in milliseconds, or use a value with unit like 30s):
1
2
3
'OPTIONS': {
'options': '-c statement_timeout=30000',
}
Other useful timeouts you can pass the same way:
- lock_timeout – How long to wait for a lock before giving up (e.g.
-c lock_timeout=5000for 5 seconds). - idle_in_transaction_session_timeout – Close connections that stay in a transaction but idle (helps avoid holding locks or bloating connection count). For example
-c idle_in_transaction_session_timeout=60000(60 seconds).
You can combine them: 'options': '-c timezone=UTC -c statement_timeout=30000 -c idle_in_transaction_session_timeout=60000'.
Connection timeout – How long to wait when opening the connection is controlled by connect_timeout in OPTIONS (seconds), as in the table above. That is separate from how long a single query may run.
Retries – Django does not have a built-in setting for retrying failed queries or failed connections. If a query or connection fails, the exception is raised to your code. You can:
- Set CONN_HEALTH_CHECKS to
True(Django 4.1+) on the database config so Django checks that the connection is still usable before each request when using persistent connections. That reduces “stale connection” errors after a restart or idle timeout but does not retry a failed operation. - Implement retries in your own code (e.g. catch
OperationalError, reconnect or retry a limited number of times). - Use a third-party package (e.g.
django-dbconn-retry) that patches the database backend to retry once on connection failures, which can help with poolers or load balancers that close connections.
There is no OPTIONS key or Django setting that means “retry this query N times”.
One settings file or split by environment?
Most projects start with a single config/settings.py. Use os.environ.get() for database credentials so the same file works locally (with your .env or shell exports) and in production (with real env vars). You do not need to split settings to use PostgreSQL.
If you prefer to separate local and production config, use a settings package:
- config/settings/base.py – Shared settings, including
DATABASESwith env vars. No secrets; onlyos.environ.get(...). - config/settings/local.py –
from .base import *then overrideDEBUG = True,ALLOWED_HOSTS, etc. - config/settings/production.py –
from .base import *then overrideDEBUG = False,ALLOWED_HOSTS, and any production-only options.
Run or deploy with DJANGO_SETTINGS_MODULE=config.settings.local or config.settings.production. The database config stays in base.py; only the environment (and thus the env vars) changes.
Using a DATABASE_URL (e.g. Heroku, Render)
If your host gives you a single URL like postgresql://user:pass@host:5432/dbname, you can use dj-database-url:
1
uv add dj-database-url
In settings.py:
1
2
3
4
5
6
7
8
9
import dj_database_url
DATABASES = {
'default': dj_database_url.config(
default=os.environ.get('DATABASE_URL'),
conn_max_age=600,
conn_health_checks=True,
)
}
This parses the URL and sets ENGINE, NAME, USER, PASSWORD, HOST, PORT for you. conn_max_age and conn_health_checks are Django options (see Connection pooling).
Step 4: Run Migrations and Verify
Database and user exist; settings.py points at them. Create Django’s tables and confirm the connection.
How migrations work and creating the first migration
Migrations are Django’s way of turning model changes into schema changes. Each migration is a small file that describes operations: create table, add column, and so on. Django records what has been applied in django_migrations so it only runs new ones.
Built-in apps – Apps that ship with Django (django.contrib.auth, admin, sessions, contenttypes, etc.) already include migration files. You do not create those; you only apply them. The first time you run migrate on a new database, Django applies all of these and creates the initial tables.
Your own apps – When you add an app (e.g. with startapp) and define models in models.py, you create migrations for that app by running:
1
uv run python manage.py makemigrations myapp
Django compares your models to the current migration state and generates a new migration file under myapp/migrations/ (e.g. 0001_initial.py). That file is the “first migration” for that app: it creates the tables and columns for your models. You then run migrate to apply it to the database. If you change a model later, run makemigrations again to generate a new migration, then migrate to apply it.
For the initial setup below you only need to apply existing migrations; there is nothing to create yet. Once you add your first app and models (see Optional: First App and a Model), you will run makemigrations to create that app’s first migration, then migrate to apply it.
Apply migrations and verify
From the project root (where manage.py is), run:
1
uv run python manage.py migrate
Django creates the tables for the built-in apps: auth, contenttypes, sessions, admin, etc. You should see a list of migrations applied. Each line corresponds to a migration file; Django runs the SQL and records it in django_migrations.
Check that the database is in use:
1
uv run python manage.py check
Then open a PostgreSQL shell using the same config as Django:
1
uv run python manage.py dbshell
You are connected as myapp to myapp_db. In psql run \dt to list tables. You should see auth_user, django_session, django_migrations, and others. Type \q to exit.
To confirm programmatically from Django:
1
2
from django.db import connection
connection.ensure_connection()
If that returns without error, your Django + Postgres setup is working.
Optional: First App and a Model
Skip this if you only need the database connected. To see the full flow from model to table in PostgreSQL, add a small app.
1
uv run python manage.py startapp items
Register the app in config/settings.py:
1
2
3
4
5
6
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
# ...
'items',
]
In items/models.py define a simple model:
1
2
3
4
5
from django.db import models
class Item(models.Model):
name = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
Create and run migrations:
1
2
uv run python manage.py makemigrations items
uv run python manage.py migrate
Django generates SQL and applies it to PostgreSQL. To see the SQL for a migration: uv run python manage.py sqlmigrate items 0001. Then open dbshell and run \dt again; you will see items_item. Your Django + Postgres setup is now creating tables from models. For production deployments, run migrations as part of your release process; for large tables, prefer additive migrations (nullable columns, backfill, then add constraints). The OpenAI PostgreSQL scaling post has more on migrations at scale.
Production Configuration
When you deploy, you care about credentials, connection pooling, SSL, and debugging. Here is what actually matters.
Credentials
Do not hardcode passwords. Use environment variables (DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT) or a DATABASE_URL and set them in your deployment environment or secret manager.
Connection pooling
Django does not ship a connection pool. Every worker can open its own connection, and that can exhaust PostgreSQL’s max_connections. You have two ways to fix that: reuse connections per worker with CONN_MAX_AGE, or put a pooler (PgBouncer) in front of PostgreSQL.
1. CONN_MAX_AGE (connection reuse)
By default Django opens a new connection per request and closes it when the request ends (CONN_MAX_AGE=0). Set CONN_MAX_AGE to a positive number (seconds) and Django keeps the connection open and reuses it for the next request in that process. That cuts connect/disconnect overhead. It is not a shared pool: each gunicorn worker still has its own connection.
1
2
3
4
5
6
7
8
9
10
11
12
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'myapp_db'),
'USER': os.environ.get('DB_USER', 'myapp'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'localhost'),
'PORT': os.environ.get('DB_PORT', '5432'),
'CONN_MAX_AGE': 600,
'CONN_HEALTH_CHECKS': True,
}
}
CONN_MAX_AGE is seconds to keep the connection open. 0 means close after each request. Sixty or six hundred is common. None means keep forever; only use that if you control worker count and stay well below max_connections. CONN_HEALTH_CHECKS (Django 4.1+) makes Django check that the connection is still valid before use. Use it with persistent connections so you do not reuse a connection the server or a proxy has already closed.
Total PostgreSQL connections ≈ (number of gunicorn/uwsgi workers) × (number of app instances). Example: 4 workers × 3 instances = 12 connections. Ensure PostgreSQL max_connections is greater than that. If you scale to many workers and approach max_connections, add a pooler instead of raising max_connections too high.
2. PgBouncer (shared connection pool)
When you have many workers or multiple app servers, a connection pooler sits between Django and PostgreSQL. Django connects to the pooler; the pooler keeps a smaller, fixed number of real connections to PostgreSQL and multiplexes client connections over them.
Configuring Django to use PgBouncer: Point HOST and PORT at the pooler, not PostgreSQL. No change to ENGINE; the pooler speaks the PostgreSQL protocol.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.environ.get('DB_NAME', 'myapp_db'),
'USER': os.environ.get('DB_USER', 'myapp'),
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
'HOST': os.environ.get('DB_HOST', 'pgbouncer.example.com'),
'PORT': os.environ.get('DB_PORT', '6432'),
'CONN_MAX_AGE': 0,
'OPTIONS': {
'connect_timeout': 10,
},
}
}
On the PgBouncer side you configure a database and user that proxy to the real PostgreSQL (host, port, user, password), and set pool_size (how many real connections to PostgreSQL) and pool_mode:
- session – One server connection per client connection; when the client disconnects, the server connection returns to the pool. Easiest for Django (no change in behaviour).
- transaction – One server connection per transaction; after
COMMIT/ROLLBACKthe server connection is reused. Fewer server connections, but Django uses features that assume a single connection per request (e.g. prepared statements,SETsession variables), so transaction mode can cause subtle issues unless you test carefully. Session mode is the safe default for Django. - statement – One server connection per statement (aggressive multiplexing). Not suitable for Django, which relies on transactions and session state.
Typical PgBouncer config snippet (see PgBouncer docs for full reference):
1
2
3
4
5
6
7
8
9
10
[databases]
myapp_db = host=127.0.0.1 port=5432 dbname=myapp_db
[pgbouncer]
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = session
max_client_conn = 1000
default_pool_size = 25
So up to 1000 client connections from Django can share 25 connections to PostgreSQL. Tune default_pool_size (or per-database pool_size) based on your DB’s max_connections and how many other clients use the same server.
CONN_MAX_AGE with PgBouncer – With a pooler in front, many setups use CONN_MAX_AGE=0 so each request gets a connection from the pool and returns it immediately; the pooler then reuses the underlying server connection. Alternatively you can set a small value (e.g. 60) so Django reuses its connection to PgBouncer for multiple requests and reduces reconnect overhead to the pooler; either way, the number of server connections is still limited by the pooler’s pool_size.
For more on connection pooling at scale (including read replicas and multiple pools), see How OpenAI Scales PostgreSQL to 800 Million Users.
SSL and debugging
SSL – For managed PostgreSQL (RDS, Render, etc.) set 'OPTIONS': {'sslmode': 'require'} or whatever your provider requires.
Debugging – To log every SQL statement, add to settings.py (only for local or debug): LOGGING = {'version': 1, 'handlers': {'console': {'class': 'logging.StreamHandler'}}, 'loggers': {'django.db.backends': {'level': 'DEBUG', 'handlers': ['console']}}}. Turn it off in production. For slow-query analysis in PostgreSQL, see the PostgreSQL Cheat Sheet.
Avoiding N+1 Queries
Once you have related models (e.g. Author and Book), you can hit N+1 queries: one query for the list, then one extra query per row for a related object. Django loads relations lazily. Use select_related('author') for ForeignKey and OneToOne, and prefetch_related('tags') for reverse or many-to-many so Django fetches them in one or two queries. Full walkthrough: N+1 query problem. For tuning slow queries and indexes, see the PostgreSQL Cheat Sheet and Database Indexing Explained.
Summary
Create the project with uv, add Django and psycopg2-binary, and create the app (e.g. config). Create the database and user in PostgreSQL; Django does not create the database. Set DATABASES in config/settings.py with ENGINE, NAME, USER, PASSWORD, HOST, PORT; use os.environ.get() for credentials and add OPTIONS and CONN_MAX_AGE when you need timeouts or connection reuse. Run migrate to create Django’s tables; use manage.py check, dbshell, or connection.ensure_connection() to verify. Use select_related and prefetch_related to avoid N+1 queries. In production keep credentials in env vars or DATABASE_URL, use CONN_MAX_AGE or PgBouncer for pooling, and set SSL and statement_timeout in OPTIONS as needed.
For day-to-day PostgreSQL commands and tuning, use the PostgreSQL Cheat Sheet. For scaling connections and read replicas, see How OpenAI Scales PostgreSQL.
Related posts:
- PostgreSQL Cheat Sheet – Commands, queries, and troubleshooting for PostgreSQL
- N+1 Query Problem Explained – How to fix N+1 with select_related and prefetch_related
- How OpenAI Scales PostgreSQL to 800 Million Users – Connection pooling, read replicas, and production patterns
- Database Indexing Explained – How indexes work when you tune slow queries
Further reading:
- Django Databases documentation – Official reference for ENGINE, OPTIONS, and CONN_MAX_AGE
- How to Use PostgreSQL in Django – FreeCodeCamp tutorial with step-by-step setup