data

Django with PostgreSQL in Production

How to configure, optimize, and run PostgreSQL with Django in production. Covers connection pooling, indexing, migrations, backups, monitoring, and common performance pitfalls.

⏱ 16 min read production
Database monitoring dashboard showing PostgreSQL query statistics

PostgreSQL is the database most serious Django projects end up using, and for good reason. It handles concurrent access reliably, supports JSON fields natively, offers full-text search without external services, and has a mature ecosystem of monitoring and backup tools. But running PostgreSQL well in production takes more than pointing Django at a connection string. This guide covers the configuration, connection management, indexing strategy, migration discipline, backup procedures, and monitoring patterns I rely on during real deployments. You will also find the common performance pitfalls that catch teams moving from SQLite development to PostgreSQL production. For an overview of Django’s data layer, see the ORM hub.

If you are still running SQLite in any environment beyond local development, treat this guide as a priority. SQLite has real limitations around write concurrency that will surface under any meaningful traffic.

Database configuration in Django

The basic PostgreSQL connection in Django settings:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ['DB_NAME'],
        'USER': os.environ['DB_USER'],
        'PASSWORD': os.environ['DB_PASSWORD'],
        'HOST': os.environ['DB_HOST'],
        'PORT': os.environ.get('DB_PORT', '5432'),
    }
}

Use the psycopg2-binary package for development. In production, compile psycopg2 from source for better performance, or use psycopg[binary] if you have moved to psycopg 3.

For full documentation on PostgreSQL configuration, features, and tuning, the official PostgreSQL documentation is the definitive reference.

Connection pooling

Every Django request opens a database connection by default and closes it when the response is sent. Under load, this creates and destroys hundreds of connections per second, which PostgreSQL handles poorly.

The simplest fix is CONN_MAX_AGE:

DATABASES = {
    'default': {
        # ... other settings
        'CONN_MAX_AGE': 600,  # Reuse connections for 10 minutes
    }
}

For higher traffic, use PgBouncer as an external connection pooler. PgBouncer sits between Django and PostgreSQL, maintaining a pool of warm connections:

Django workers -> PgBouncer (port 6432) -> PostgreSQL (port 5432)

Configure PgBouncer in transaction pooling mode for Django. Session pooling can interfere with Django’s transaction management and prepared statements.

Indexing strategy

Django creates indexes automatically for primary keys, unique fields, and ForeignKey fields. But these defaults rarely cover your actual query patterns.

Add indexes for fields you filter, order by, or join on frequently:

class Order(models.Model):
    customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
    status = models.CharField(max_length=20, db_index=True)
    created_at = models.DateTimeField(db_index=True)
    total = models.DecimalField(max_digits=10, decimal_places=2)

    class Meta:
        indexes = [
            models.Index(fields=['status', 'created_at']),
            models.Index(fields=['customer', '-created_at']),
        ]

Use EXPLAIN ANALYZE to verify that PostgreSQL actually uses your indexes:

EXPLAIN ANALYZE SELECT * FROM orders_order WHERE status = 'pending' ORDER BY created_at DESC;

If you see sequential scans on large tables, you either need an index or the query planner has decided the index would not help. The ORM query optimization guide covers select_related, prefetch_related, and query analysis in more detail.

Migrations in production

Migrations deserve care in production because they run against a live database. Careless migrations cause downtime.

Rules for safe production migrations:

  • Never rename a column in a single migration. Add the new column, backfill data, update code, then remove the old column in a later release.
  • Avoid RunPython operations that scan entire tables during deployment.
  • Use AddIndex(concurrently=True) for large tables so the index builds without locking writes.
  • Always test migrations against a copy of production data before running them live.
from django.contrib.postgres.operations import AddIndexConcurrently

class Migration(migrations.Migration):
    atomic = False  # Required for concurrent index creation

    operations = [
        AddIndexConcurrently(
            model_name='order',
            index=models.Index(fields=['status'], name='idx_order_status'),
        ),
    ]

PostgreSQL-specific features in Django

Django exposes several PostgreSQL-specific features through django.contrib.postgres:

Full-text search:

from django.contrib.postgres.search import SearchVector, SearchQuery

Product.objects.annotate(
    search=SearchVector('name', 'description')
).filter(search=SearchQuery('django deployment'))

JSONField:

class Config(models.Model):
    data = models.JSONField(default=dict)

# Query into JSON:
Config.objects.filter(data__settings__debug=True)

ArrayField, HStoreField, range fields: These are available when you need them, but use them deliberately. Not every project needs PostgreSQL-specific field types. Standard fields and relationships cover most use cases cleanly.

Backup and recovery

Backups are non-negotiable. Use pg_dump for logical backups:

pg_dump -Fc -h localhost -U myuser mydb > backup_$(date +%Y%m%d).dump

Restore with pg_restore:

pg_restore -h localhost -U myuser -d mydb backup_20260315.dump

For point-in-time recovery, configure WAL archiving. Managed PostgreSQL services (AWS RDS, Google Cloud SQL, etc.) handle this automatically with configurable retention.

Test your backups regularly. A backup you have never restored is not a backup. Schedule quarterly restore drills.

Monitoring

Track these PostgreSQL metrics in production:

  • Active connections: compare against max_connections
  • Query duration: identify slow queries with pg_stat_statements
  • Table bloat: dead tuples accumulate and need vacuuming
  • Index usage: identify unused indexes wasting storage and write performance
  • Replication lag: if you use read replicas

Enable pg_stat_statements in your PostgreSQL configuration:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Then query the most time-consuming queries:

SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

The logging and monitoring guide covers application-level monitoring that complements database-level metrics.

Common production pitfalls

Using SQLite in staging. Your staging environment should match production, including the database engine. Behavior differences between SQLite and PostgreSQL will surprise you.

Not setting statement timeouts. A runaway query can lock tables and cascade failures. Set statement_timeout in PostgreSQL configuration.

Ignoring vacuum schedules. PostgreSQL’s autovacuum handles most maintenance, but heavily updated tables may need tuned autovacuum parameters.

Storing large files in the database. Use the filesystem or object storage for file data. PostgreSQL can handle binary data, but it was not designed to be a file server.

Frequently asked questions

Should I use psycopg2 or psycopg3? Psycopg 3 is the modern choice with better async support and type handling. Django 5.x supports both. For new projects, psycopg 3 is recommended. Existing projects on psycopg2 can migrate when convenient.

How do I handle read replicas? Use Django’s database router to direct read queries to a replica and writes to the primary. The DATABASE_ROUTERS setting controls this routing logic.

When should I consider a managed database service? Unless you have a dedicated DBA, use a managed service. The operational burden of running PostgreSQL yourself, including backups, patching, failover, and monitoring, is significant. Managed services handle this for a predictable cost.

How large a dataset can Django handle with PostgreSQL? Django with PostgreSQL scales to billions of rows with proper indexing, query optimization, and connection management. The bottleneck is rarely the framework. It is almost always query patterns and indexing.