Filter concepts by levelShowing all levels.

Django · PostgreSQL with Django

PostgreSQL with Django

PostgreSQL independently of the ORM — indexes, transactions, locking, query plans, and operating at scale

  • B-tree default; GIN/GiST/BRIN for the resteach index type earns its place on a specific query shape
  • Read Committed re-snapshots per statementRepeatable Read/Serializable snapshot once, need retry-on-conflict
  • A deadlock is a circular waitPostgreSQL breaks it by aborting one transaction, unpredictably
  • EXPLAIN ANALYZE runs the query for reala big estimate-vs-actual gap usually means stale statistics

1.Index types

B-tree is the default and correct choice for most cases — equality and range queries (<, <=, =, >=, >, BETWEEN, IN) on sortable data. GIN indexes arrays/JSONB/full-text (containment queries: does this array/document contain X). GiST handles geometric/nearest-neighbor queries. BRIN is a tiny, cheap index for huge tables where a column's values roughly follow physical row order (e.g. an auto-incrementing id or a timestamp).

Key features

  • B-tree is the default (CREATE INDEX ON table (column) — no USING clause needed) — correct for the vast majority of equality/range queries
  • GIN (Generalized Inverted Index) is for composite/multi-valued data — arrays, JSONB, full-text search — answering "does this contain X" queries
  • GiST (Generalized Search Tree) supports geometric data and nearest-neighbor (ORDER BY ... <-> ...) queries
  • BRIN (Block Range Index) is a tiny, cheap index for huge tables whose column values correlate with physical row order (e.g. a monotonically increasing id or timestamp)
  • Hash indexes support only equality (=) — narrower than B-tree, rarely the right choice since B-tree already handles equality plus everything else

Choosing an index type

Choosing an index type
Query shapeIndex type
Equality/range on an ordinary columnB-tree (default)
"Does this array/JSONB contain X" / full-text searchGIN
Geometric / nearest-neighborGiST
Huge table, values roughly ordered by insertionBRIN
Choosing an index type by query shape

B-tree (default)

equality/range on ordinary columns

GIN

arrays/JSONB/full-text containment

GiST

geometric / nearest-neighbor

BRIN

huge, insertion-ordered tables

  1. B-tree (default) — equality/range on ordinary columns
  2. GIN — arrays/JSONB/full-text containment
  3. GiST — geometric / nearest-neighbor
  4. BRIN — huge, insertion-ordered tables

Command

sql
CREATE INDEX idx_name ON table_name (column_name);            -- B-tree
CREATE INDEX idx_name ON table_name USING GIN (jsonb_column);  -- GIN

B-tree is the default and handles equality/range on ordinary columns; GIN is for arrays/JSONB/full-text containment queries; GiST is for geometric/nearest-neighbor; BRIN is a tiny index for huge, insertion-ordered tables. Every index has a real write cost — add them in response to an observed query pattern, not speculatively.

2.Transactions and isolation levels

A transaction (BEGIN...COMMIT/ROLLBACK) groups statements so they succeed or fail together. PostgreSQL offers three isolation levels — Read Committed (the default: each statement sees data committed as of when THAT statement started), Repeatable Read (the whole transaction sees one consistent snapshot from its start), and Serializable (behaves as if transactions ran one at a time, detecting and rejecting conflicts). Stricter isolation prevents more anomalies but can raise a serialization error the application must be ready to retry.

Key features

  • Read Committed is PostgreSQL's default — each statement (not the whole transaction) sees data committed as of when that statement began
  • Repeatable Read gives the whole transaction one consistent snapshot from its start — prevents non-repeatable reads, but concurrent conflicting writes raise a serialization error
  • Serializable is the strictest — behaves as if every transaction ran one at a time, detecting even indirect read/write conflicts between concurrent transactions
  • Repeatable Read and Serializable BOTH require the application to catch a serialization failure and retry the transaction — this is expected, normal behavior at those levels, not a bug
  • PostgreSQL never allows dirty reads (reading another transaction's uncommitted changes) at ANY isolation level, even though the SQL standard's loosest level (Read Uncommitted) technically permits it

What each isolation level prevents

What each isolation level prevents
LevelPreventsStill possible
Read Committed (default)dirty readsnon-repeatable reads, phantom reads
Repeatable Readdirty reads, non-repeatable reads, phantom readsserialization anomalies (raises an error instead)
Serializableall of the abovenothing — strictest guarantee, retry required on conflict
Three isolation levels, stricter going up

Serializable

behaves as if transactions ran one at a time — retry on conflict

Repeatable Read

one consistent snapshot for the whole transaction

Read Committed (default)

each statement sees a fresh snapshot of committed data

  1. Serializable — behaves as if transactions ran one at a time — retry on conflict
  2. Repeatable Read — one consistent snapshot for the whole transaction
  3. Read Committed (default) — each statement sees a fresh snapshot of committed data

Command

sql
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- statements here
COMMIT;

Read Committed (default) re-snapshots per statement; Repeatable Read snapshots once for the whole transaction; Serializable adds full conflict detection. Repeatable Read and Serializable both require the application to catch and retry a serialization failure — that is expected behavior at those levels, not a bug.

3.Locks and deadlocks

A lock prevents two transactions from conflicting over the same row/table at once — UPDATE/DELETE/SELECT FOR UPDATE all take row-level locks automatically. A deadlock happens when two transactions each hold a lock the other one wants, so neither can proceed — PostgreSQL detects this automatically and aborts one of the transactions (unpredictable which one) so the other can continue. The real fix is prevention: always acquire locks on multiple objects in the same, consistent order across every code path.

Key features

  • UPDATE, DELETE, and SELECT ... FOR UPDATE all take row-level locks automatically — the calling code does not opt in explicitly for ordinary writes
  • A deadlock is a CIRCULAR wait: A holds what B wants, B holds what A wants — neither can proceed without the other releasing first
  • PostgreSQL automatically detects deadlocks and aborts one of the involved transactions so the other(s) can continue — the aborted transaction receives an error and must be retried
  • Which transaction gets aborted in a deadlock is difficult to predict and explicitly should not be relied upon, per PostgreSQL's own documentation
  • The documented best defense is prevention: ensure every code path acquires locks on multiple objects in the SAME, consistent order — if that can't be guaranteed, retry transactions that fail due to deadlock

Deadlock: the classic circular-wait shape

Deadlock: the classic circular-wait shape
StepTransaction ATransaction B
1locks row 1locks row 2
2wants row 2 — waitswants row 1 — waits
3stuckstuck — PostgreSQL detects the cycle and aborts one
The classic circular-wait deadlock
Transaction A
row 1
row 2
Transaction B
  1. 1locks row 1
  2. 2locks row 2
  3. 3wants row 2 — waits
  4. 4wants row 1 — waits
  1. Transaction A → row 1: locks row 1
  2. Transaction B → row 2: locks row 2
  3. Transaction A → row 2: wants row 2 — waits
  4. Transaction B → row 1: wants row 1 — waits

Command

sql
-- always lock/update rows in the SAME order across every code path
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(1, 2);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(1, 2);

A deadlock is a circular wait between two transactions — PostgreSQL detects and breaks it by aborting one, unpredictably. The real defense is always acquiring locks on multiple rows/tables in the same, consistent order across every code path; when a deadlock does happen, retry the ENTIRE transaction from its start, not just the failed statement.

4.Query plans: EXPLAIN and EXPLAIN ANALYZE

EXPLAIN shows the query planner's ESTIMATED plan (node types, estimated cost, estimated row counts) without running the query. EXPLAIN ANALYZE actually RUNS the query and adds real measured numbers (actual time, actual rows, loop count) alongside the estimates — comparing the two is how a wildly-wrong estimate (usually from stale statistics) gets caught. A Seq Scan on a large table where an index exists, or "rows removed by filter" in the thousands, are the two most common red flags.

Key features

  • EXPLAIN shows estimated cost/rows without executing the query — safe to run on anything, including a query that would modify data (though the modification would still occur unless wrapped in a ROLLBACK)
  • EXPLAIN ANALYZE actually executes the query and reports real measured numbers — use inside a transaction you intend to ROLLBACK for a modifying query, to see the plan without committing the change
  • A Seq Scan on a large table, where a WHERE clause could plausibly use an index instead, is one of the most common signals of a missing or unused index
  • "Rows Removed by Filter" being large relative to the final result means the database scanned far more rows than it needed to — the filtering condition should ideally be handled by an index instead
  • A large gap between "estimated rows" and "actual rows" (visible only in EXPLAIN ANALYZE) usually means table statistics are stale — running ANALYZE on the table often fixes the planner's next decision

Common plan-reading red flags

Common plan-reading red flags
What you seeLikely cause
Seq Scan on a large table with a selective WHEREmissing index, or the planner isn't using an existing one
Large "Rows Removed by Filter"the filter should be index-backed instead of scan-and-discard
Estimated rows far off from actual rows (EXPLAIN ANALYZE)stale statistics — run ANALYZE
A node with a high loop countmultiply actual time × loops for the real total cost of that node
Reading a plan — the missing-index red flag

Seq Scan on orders (cost=0.00..35000.00 rows=5000 width=200) (actual time=0.045..2500.000 rows=5000 loops=1) Filter: (customer_id = 123) Rows Removed by Filter: 995000

Seq Scan on orders

Seq Scan — scans every row — a red flag on a large table with a selective WHERE

actual time=0.045..2500.000

actual time — from EXPLAIN ANALYZE — real measured time, not an estimate

Rows Removed by Filter: 995000

Rows Removed by Filter — 99.5% wasted scan — the clearest missing-index signal

  • Whole: Seq Scan on orders (cost=0.00..35000.00 rows=5000 width=200) (actual time=0.045..2500.000 rows=5000 loops=1) Filter: (customer_id = 123) Rows Removed by Filter: 995000
  • Seq Scan on orders — Seq Scan: scans every row — a red flag on a large table with a selective WHERE
  • actual time=0.045..2500.000 — actual time: from EXPLAIN ANALYZE — real measured time, not an estimate
  • Rows Removed by Filter: 995000 — Rows Removed by Filter: 99.5% wasted scan — the clearest missing-index signal

Command

sql
EXPLAIN SELECT ...;           -- estimated plan only, safe, doesn't execute
EXPLAIN ANALYZE SELECT ...;   -- actually runs the query, adds real timing

EXPLAIN shows the planner's estimate without running anything; EXPLAIN ANALYZE actually executes the query and adds real timing — a big estimate-vs-actual gap usually means stale statistics (run ANALYZE). A Seq Scan with a large "Rows Removed by Filter" on a big table is the clearest missing-index signal. Wrap EXPLAIN ANALYZE on a modifying statement in BEGIN/ROLLBACK.

5.VACUUM and ANALYZE

PostgreSQL's MVCC never immediately deletes an old row version on UPDATE/DELETE — VACUUM reclaims that dead-tuple space, updates the visibility map, and prevents transaction-id wraparound. VACUUM FULL rewrites the whole table to actually shrink it on disk, but takes an exclusive lock (blocking everything) — plain VACUUM runs alongside normal traffic and is almost always the right one. autovacuum runs both VACUUM and ANALYZE automatically in the background; ANALYZE separately updates the statistics the query planner relies on to choose good plans.

Key features

  • VACUUM reclaims dead-tuple space for REUSE within the table — it rarely shrinks the file on disk or returns space to the OS
  • VACUUM FULL actually rewrites and compacts the table, returning space to the OS — but takes an ACCESS EXCLUSIVE lock, blocking all reads and writes for its duration
  • autovacuum (a background daemon, on by default) runs VACUUM and ANALYZE automatically based on how much a table has changed — disabling it is strongly discouraged except for extremely predictable workloads
  • ANALYZE updates the query planner's statistics (via sampling, not a full scan) — stale statistics are a common cause of the query planner choosing a bad plan
  • Every table must be vacuumed at least once every ~2 billion transactions to prevent transaction ID wraparound — a scenario PostgreSQL's own docs describe as leading to catastrophic data loss if truly ignored

VACUUM vs VACUUM FULL

VACUUM vs VACUUM FULL
AspectVACUUMVACUUM FULL
Lock takenSHARE UPDATE EXCLUSIVE — normal traffic continuesACCESS EXCLUSIVE — blocks everything
Reclaims space forreuse within the tableactually shrinks the file, returns space to OS
Used by autovacuum?yesnever
When to useroutine, ongoing maintenance (the default choice)rare — a table that is drastically over-bloated
VACUUM vs VACUUM FULL

VACUUM

  • Non-blocking — normal traffic continues
  • Reclaims space for reuse within the table
  • What autovacuum runs — the routine choice

VACUUM FULL

  • ACCESS EXCLUSIVE — blocks everything
  • Actually shrinks the file, returns space to OS
  • Rare — only for a drastically bloated table
  • VACUUM
    • Non-blocking — normal traffic continues
    • Reclaims space for reuse within the table
    • What autovacuum runs — the routine choice
  • VACUUM FULL
    • ACCESS EXCLUSIVE — blocks everything
    • Actually shrinks the file, returns space to OS
    • Rare — only for a drastically bloated table

Command

sql
VACUUM ANALYZE table_name;   -- routine maintenance, safe to run alongside traffic
ANALYZE table_name;           -- refresh planner statistics only, no space reclamation

VACUUM reclaims dead-tuple space for reuse (non-blocking, routine); VACUUM FULL actually shrinks the table on disk but blocks everything (rare). autovacuum runs both automatically and should almost never be disabled outright. ANALYZE refreshes the query planner's statistics — run it explicitly after a large bulk load rather than waiting for autovacuum's own schedule.

6.Connection limits, replicas, and partitioning

max_connections caps concurrent connections — each one has real memory/process overhead, so raising it arbitrarily high is the wrong fix; a connection pooler (PgBouncer) that multiplexes many app connections onto fewer real PostgreSQL connections is the standard answer. A replica (streaming replication) is a read-only copy of the primary — asynchronous (default, some lag possible) or synchronous (no data loss, slower commits). Partitioning splits one logical table into physically separate pieces (range/list/hash) so queries can skip irrelevant partitions and bulk operations (like dropping old data) become near-instant.

Key features

  • max_connections defaults to 100 and can only be set at server start — PostgreSQL pre-allocates shared memory sized to this value, so raising it has a real memory cost even for connections that sit idle
  • A connection pooler (PgBouncer is the common choice) lets many application connections share a much smaller pool of real PostgreSQL connections — the standard fix instead of raising max_connections arbitrarily
  • A hot standby (replica) can serve read-only queries while staying synced from the primary; a warm standby cannot accept any connections until promoted
  • Asynchronous replication (the default) has minimal performance impact but risks losing recent transactions on failover; synchronous replication guarantees no data loss but slows every commit down
  • Range/list/hash partitioning split one logical table into physical pieces — partition pruning lets the planner skip partitions a query cannot match, and dropping/detaching a whole partition is far faster than deleting the same rows individually

Three ways of outgrowing a single instance/table

Three ways of outgrowing a single instance/table
ProblemAnswer
Too many concurrent app connectionsa connection pooler (PgBouncer), not a higher max_connections
Read load exceeds one server's capacitystreaming replication — hot standby replicas serve reads
One table has grown too large to manage/query efficientlypartitioning (range/list/hash), enabling partition pruning + fast bulk maintenance

Command

sql
CREATE TABLE t (...) PARTITION BY RANGE (col);
CREATE TABLE t_p1 PARTITION OF t FOR VALUES FROM (x) TO (y);

A connection pooler (PgBouncer), not a higher max_connections, is the standard fix for too many concurrent app connections — each connection has real, unavoidable server-side overhead. Async replication (the default) can lag — route read-your-own-write queries to the primary, not a replica. Partitioning splits one large table into physical pieces for partition pruning and near-instant bulk maintenance (DROP/DETACH vs. DELETE).

Remember

  • B-tree (default) for equality/range; GIN for array/JSONB containment; BRIN for huge, insertion-ordered tables.
  • Read Committed (default) re-snapshots per statement; Repeatable Read/Serializable snapshot once and require retry-on-conflict.
  • A deadlock is a circular wait — the real fix is a consistent lock order across every code path, not relying on PostgreSQL's detector.
  • EXPLAIN ANALYZE actually runs the query; a big estimated-vs-actual gap usually means stale statistics — run ANALYZE.

Pro tip

  • A connection pooler (PgBouncer), not a higher max_connections, is the standard fix for too many concurrent app connections.
  • Async replication (the default) can lag — route read-your-own-write queries to the primary, not a replica.

Next up

  • Database Constraints
  • Transactions
  • select_for_update()

Sources

  • Index types — postgresql.org/docs/current/indexes-types.html
  • Transaction isolation — postgresql.org/docs/current/transaction-iso.html
  • Explicit locking, deadlocks — postgresql.org/docs/current/explicit-locking.html
  • Using EXPLAIN — postgresql.org/docs/current/using-explain.html
  • Routine vacuuming — postgresql.org/docs/current/routine-vacuuming.html
  • Every claim checked against PostgreSQL 17 docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.