Filter on a condition (e.g., status = 'active'), a partial index can be 10-100x smaller than a Full index while providing the same query performance. The key insight: do not index data your Queries never look for.
An expression index indexes the result of an expression or function, not just a column value.
-- Case-insensitive email lookup:
CREATE INDEX idx_users_lower_email ON users ( LOWER (email));
-- Indexing a computed value:
CREATE INDEX idx_orders_total ON orders (quantity * unit_price);
CREATE INDEX idx_events_payload ON events ((payload ->> ' event_type ' ));
CREATE INDEX idx_docs_search ON documents USING gin (to_tsvector( ' english ' , content));
The query must use the exact same expression to use the index:
-- Uses the expression index:
SELECT * FROM users WHERE LOWER (email) = ' ada@example.com ' ;
-- Does NOT use the expression index (different expression):
SELECT * FROM users WHERE email = ' ADA@EXAMPLE.COM ' ;
Designed for composite types where you need to find rows containing specific elements within a Value: arrays, full-text search, JSONB.
CREATE INDEX idx_tags ON articles USING gin (tags);
SELECT * FROM articles WHERE tags @ > ARRAY ['database', 'performance'];
CREATE INDEX idx_metadata ON documents USING gin (metadata);
SELECT * FROM documents WHERE metadata @ > ' {"category": "engineering"} ' ;
CREATE INDEX idx_fts ON documents USING gin (to_tsvector( ' english ' , body));
SELECT * FROM documents WHERE to_tsvector( ' english ' , body) @@ to_tsquery( ' english ' , ' database & performance ' );
A framework for building balanced tree structures over custom data types. Used for geometric data, Range types, and full-text search.
CREATE INDEX idx_locations ON places USING gist ( location );
SELECT * FROM places WHERE location << circle ' ((0,0), 5) ' ;
CREATE INDEX idx_date_ranges ON reservations USING gist (date_range);
SELECT * FROM reservations WHERE date_range && ' [2024-01-01, 2024-12-31] ' ::daterange;
Stores summary information about ranges of physical table pages. Extremely small but only effective When data is physically correlated with the indexed column (e.g., a timestamp column where rows are Inserted in chronological order).
-- For time-series data where newer rows have higher timestamps:
CREATE INDEX idx_sensor_readings_ts ON sensor_readings USING brin ( timestamp );
A BRIN index for a 100GB table might be only a few hundred KB, compared to several GB for a B-tree Index. The trade-off: the index only tells the query planner which page ranges might contain Matching rows, so it must still scan those pages.
EXPLAIN shows the query planner’s execution plan. EXPLAIN ANALYZE actually executes the query And shows actual timing and row counts alongside the planner’s estimates.
SELECT e . first_name , e . last_name , d . department_name
JOIN departments d ON e . department_id = d . dept_id
Limit (cost=0.56..1042.87 rows=20 width=40) (actual time=0.042..0.178 rows=20 loops=1)
-> Sort (cost=0.56..1042.87 rows=20000 width=40) (actual time=0.041..0.174 rows=20 loops=1)
Sort Method: top-N heapsort Memory: 27kB
-> Nested Loop (cost=0.28..942.78 rows=20000 width=40) (actual time=0.021..0.124 rows=43 loops=1)
-> Index Scan using idx_emp_salary on employees e (cost=0.28..472.89 rows=20000 width=32) (actual time=0.015..0.072 rows=43 loops=1)
Index Cond: (salary > 100000)
-> Index Scan using departments_pkey on departments d (cost=0.00..0.02 rows=1 width=16) (actual time=0.001..0.001 rows=1 loops=43)
Index Cond: (dept_id = e.department_id)
Key fields to examine:
Field Meaning costPlanner’s estimate of relative cost (lower is better) rowsPlanner’s estimate of rows processed at each node actual timeActual time in milliseconds for this node (from ANALYZE) actual rowsActual rows processed (from ANALYZE) loopsNumber of times this node was executed (from ANALYZE)
Node Description Seq ScanFull table scan; reads every row Index ScanUses a B-tree index to find matching rows (reads heap for each) Index Only ScanAll data comes from the index; no heap access needed Bitmap ScanTwo-phase: bitmap of matching pages, then fetch in page order Nested LoopFor each outer row, look up matching inner rows (good for small) Hash JoinBuild hash table on inner, probe with outer (good for large) Merge JoinBoth inputs sorted, merge in one pass (good for pre-sorted data) SortIn-memory sort (quicksort) or external sort (merge sort to disk) AggregateHash aggregate or Group Aggregate (sorted) LimitStops processing after N rows
The query planner relies on statistics gathered by ANALYZE. If statistics are stale or the table Has a highly skewed data distribution, the planner may choose a suboptimal plan.
-- Update statistics for a specific table:
-- Update statistics for a specific column with more detail:
ALTER TABLE employees ALTER COLUMN salary SET STATISTICS 1000 ;
-- Disable a specific plan node type for a single query:
SET enable_seqscan = off ;
SET enable_nestloop = off ;
SET enable_hashjoin = off ;
-- Check current statistics:
SELECT attname, n_distinct, correlation, null_frac
WHERE tablename = ' employees ' ;
A sequential scan reads every page of the table in physical order. An index scan reads the index First, then fetches matching rows from the table. Counterintuitively, a sequential scan can be Faster than an index scan when:
The table is small (fits in a few pages) A large percentage of rows match the query (e.g., WHERE status = 'active' when 80% of rows are active) The index and table are both on disk, causing random I/O for each index entry The planner’s decision threshold is approximately when the query selects more than 5-15% of the Table (varies by index type, data distribution, and hardware).
A bitmap scan is a two-phase strategy:
Bitmap creation phase : scan the index and build a bitmap of matching heap page numbersBitmap heap scan phase : fetch matching pages in physical page order (sequential I/O)This avoids the random I/O of a plain index scan while still benefiting from index selectivity.
Index -> Page 47 -> Index -> Page 2 -> Index -> Page 47 -> Index -> Page 893
(each heap access is a random disk seek)
Bitmap Scan (sequential I/O):
Index -> build bitmap {2, 47, 47, 893} -> sort -> fetch pages 2, 47, 893 in order
(heap pages fetched in physical order, minimizing seeks)
Nested Loop Join:
For each row in the outer table, look up matching rows in the inner table using an index Cost: O(N_{\mathrm{outer} \times \log N_{\mathrm{inner}) with index on inner Best for: small outer table, indexed inner table, or when only a few rows match Hash Join:
Build an in-memory hash table from the inner (smaller) table, then probe with the outer table Cost: O(N_{\mathrm{inner} + N_{\mathrm{outer}) Best for: large tables, equi-joins, when the inner table fits in memory (work_mem) Fallback: if the hash table exceeds work_memSpills to disk (slow) Merge Join:
Both inputs must be sorted on the join key; merge in a single pass Cost: O(N_{\mathrm{outer} \log N_{\mathrm{outer} + N_{\mathrm{inner} \log N_{\mathrm{inner}) for sorting, O(N_{\mathrm{outer} + N_{\mathrm{inner}) for merge Best for: pre-sorted inputs, large result sets, range joins PostgreSQL uses a cost model based on:
seq_page_cost (default 1.0): cost of reading a sequential page from diskrandom_page_cost (default 4.0): cost of reading a random page from diskcpu_tuple_cost (default 0.01): cost of processing each rowcpu_index_tuple_cost (default 0.005): cost of processing each index entrycpu_operator_cost (default 0.0025): cost of evaluating each operatorFor SSDs, random_page_cost should be lowered (1.1-1.5) because random reads are nearly as fast as Sequential reads. For HDD arrays, the default of 4.0 is reasonable.
The query planner’s decisions are only as good as the statistics it works from. PostgreSQL collects Statistics via ANALYZE (run automatically by autovacuum) and stores them in pg_statistic (viewable through pg_stats).
Column Meaning n_distinctEstimated number of distinct values (-1 means “proportional to row count”) most_common_valsMost frequent values in the column most_common_freqsFrequency of the most common values histogram_boundsBucket boundaries for value distribution correlationCorrelation between physical row order and column value order (1.0 = perfectly sorted, -1.0 = perfectly reverse sorted) null_fracFraction of rows where the column is NULL
When columns are correlated (e.g., city and zip_code), the planner may underestimate selectivity Because it treats each column independently. PostgreSQL 10+ supports extended statistics:
CREATE STATISTICS s_emp_dept_role (ndistinct, dependencies)
ON department_id, role_id FROM employees;
PostgreSQL uses Multi-Version Concurrency Control (MVCC), which means updates and deletes do not Immediately reclaim space. Dead tuples (old row versions) accumulate until VACUUM processes them.
Autovacuum runs automatically based on thresholds:
-- View autovacuum settings:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables;
ALTER TABLE employees SET (autovacuum_vacuum_scale_factor = 0 . 05 );
ALTER TABLE employees SET (autovacuum_analyze_scale_factor = 0 . 02 );
ALTER TABLE employees SET (autovacuum_vacuum_cost_delay = 20 );
After heavy UPDATE/DELETE activity, B-tree indexes accumulate empty space from page splits and Deleted entries. Check for index bloat:
SELECT pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
pg_size_pretty(pg_relation_size(indexrelid) - pg_stat_get_dead_tuples(indexrelid):: bigint ) AS useful_size
FROM pg_stat_user_indexes
WHERE schemaname = ' public '
ORDER BY pg_relation_size(indexrelid) DESC ;
Solutions for index bloat:
-- Rebuild a single index (locks the table briefly):
REINDEX INDEX idx_employees_email;
-- Rebuild all indexes concurrently (no table lock):
REINDEX INDEX CONCURRENTLY idx_employees_email;
REINDEX TABLE CONCURRENTLY employees;
For severe bloat that REINDEX cannot address (heap bloat), use pg_repack:
pg_repack -d mydb -t employees
pg_repack creates a new table, copies data, rebuilds indexes, and swaps the tables with a brief Lock window.
Indexes impose costs:
Write amplification : every INSERT, UPDATE, and DELETE must update all indexes on the tableStorage : each index consumes disk space (often 10-30% of the table size per index)Planner overhead : more indexes means more plans to evaluate during query planningVACUUM overhead : dead index entries must be cleaned upGuidelines for when to skip indexing:
Small tables (a few thousand rows): a sequential scan is already fastWrite-heavy tables with few reads: the write cost outweighs the read benefitColumns with low cardinality : a boolean column with 99% TRUE values is not worth indexing (use a partial index instead)Bulk load operations : drop indexes before loading, recreate afterTemporary/staging tables : data is ephemeral, no point in indexingEvery database connection consumes memory (PostgreSQL: approximately 5-10MB per connection for the Process, plus work_mem for queries). A web application with 500 concurrent connections can consume Several GB just for connection overhead.
PgBouncer (PostgreSQL):
mydb = host = 127.0.0.1 port = 5432 dbname = mydb
Pool modes:
Mode Behaviour sessionServer connection held for the entire client session transactionServer connection held only for the duration of a transaction (recommended) statementServer connection returned to pool after each statement (limited, breaks prepared statements)