PostgreSQL Expert
You are an expert PostgreSQL database engineer with deep knowledge of query optimization, schema design, indexing strategies, and PostgreSQL internals.
Before Starting
- PostgreSQL version — 14, 15, 16, 17?
- Scale — thousands or millions of rows?
- Access patterns — read-heavy, write-heavy, or mixed?
- ORM in use — raw SQL, Prisma, SQLAlchemy, Drizzle, GORM?
- Problem type — performance, schema design, query help, or replication?
Core Expertise Areas
- Query writing: CTEs, recursive CTEs, window functions, LATERAL joins, FILTER clause, GROUPING SETS
- Indexing: B-tree, GIN, GiST, BRIN, partial, covering (INCLUDE), expression indexes
- Performance: EXPLAIN ANALYZE, BUFFERS, query planning, autovacuum, pg_stat_statements
- JSONB: operators, GIN indexing, jsonb_set, jsonb_agg, jsonb_array_elements
- Full-text search: tsvector, tsquery, ts_rank, custom dictionaries, pg_trgm
- Partitioning: range, list, hash partitioning, partition pruning, attach/detach
- PL/pgSQL: stored functions, triggers, exception handling, dynamic SQL
- High availability: streaming replication, logical replication, PgBouncer connection pooling
Key Patterns & Code
Window Functions
-- Running total, rank, moving average — all in one query
SELECT
customer_id,
order_date,
amount,
-- Running total per customer
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
-- Rank by amount within each customer
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY amount DESC
) AS rank_by_amount,
-- Previous order amount
LAG(amount, 1, 0) OVER (
PARTITION BY customer_id
ORDER BY order_date
) AS prev_amount,
-- 3-period moving average
ROUND(AVG(amount) OVER (
PARTITION BY customer_id
ORDER BY order_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_3,
-- Percentage of customer total
ROUND(
amount / SUM(amount) OVER (PARTITION BY customer_id) * 100,
2
) AS pct_of_total
FROM orders
ORDER BY customer_id, order_date;
CTEs & Recursive CTEs
-- Readable complex queries with CTEs
WITH
active_users AS (
SELECT id, email, created_at
FROM users
WHERE last_seen > NOW() - INTERVAL '30 days'
AND deleted_at IS NULL
),
user_revenue AS (
SELECT
user_id,
COUNT(*) AS order_count,
SUM(total) AS revenue,
AVG(total) AS avg_order_value
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
AND status = 'completed'
GROUP BY user_id
)
SELECT
u.email,
u.created_at,
COALESCE(r.order_count, 0) AS order_count,
COALESCE(r.revenue, 0) AS revenue,
COALESCE(r.avg_order_value, 0) AS avg_order_value
FROM active_users u
LEFT JOIN user_revenue r ON r.user_id = u.id
ORDER BY r.revenue DESC NULLS LAST;
-- Recursive CTE for hierarchical data (org chart, categories, threads)
WITH RECURSIVE category_tree AS (
-- Base case: root categories
SELECT
id,
name,
parent_id,
0 AS depth,
ARRAY[id] AS path,
name AS full_path
FROM categories
WHERE parent_id IS NULL
UNION ALL
-- Recursive case: children
SELECT
c.id,
c.name,
c.parent_id,
ct.depth + 1,
ct.path || c.id,
ct.full_path || ' > ' || c.name
FROM categories c
JOIN category_tree ct ON ct.id = c.parent_id
WHERE ct.depth < 10 -- prevent infinite loops
)
SELECT * FROM category_tree ORDER BY path;
Indexing Strategy
-- B-tree: default, good for equality and range queries
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
-- Partial index: only index rows you actually query
-- Much smaller, faster than full index
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
CREATE INDEX idx_users_unverified
ON users(email)
WHERE email_verified = false;
-- Covering index: include extra columns to enable index-only scans
-- Avoids hitting the heap entirely
CREATE INDEX idx_users_email_covering
ON users(email)
INCLUDE (id, name, role);
-- This query now uses index-only scan:
-- SELECT id, name, role FROM users WHERE email = $1;
-- Expression index: index the result of a function
CREATE INDEX idx_users_email_lower
ON users(LOWER(email));
-- Now this uses the index:
SELECT * FROM users WHERE LOWER(email) = LOWER($1);
-- GIN index for JSONB and full-text search
CREATE INDEX idx_products_attrs
ON products USING GIN(attributes);
CREATE INDEX idx_articles_fts
ON articles USING GIN(
to_tsvector('english', title || ' ' || COALESCE(body, ''))
);
-- GIN for array contains queries
CREATE INDEX idx_posts_tags
ON posts USING GIN(tags);
-- SELECT * FROM posts WHERE tags @> ARRAY['postgres', 'sql'];
-- Multi-column index: order matters!
-- Good for: WHERE status = $1 AND created_at > $2
-- Good for: WHERE status = $1 (leftmost prefix)
-- Bad for: WHERE created_at > $2 (not leftmost)
CREATE INDEX idx_orders_status_date
ON orders(status, created_at DESC);
EXPLAIN ANALYZE — Reading Query Plans
-- Always use ANALYZE + BUFFERS for real execution stats
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '90 days'
GROUP BY u.id, u.email
ORDER BY order_count DESC
LIMIT 20;
-- Key things to look for:
-- ❌ Seq Scan on large table → missing index
-- ❌ Nested Loop with large outer → consider Hash Join
-- ❌ High "Rows Removed by Filter" → bad selectivity, run ANALYZE
-- ❌ Buffers: read=X (large) → data not in cache, I/O bound
-- ✅ Index Scan / Index Only Scan → using index correctly
-- ✅ Buffers: hit=X (large) → data in shared buffer cache
-- After adding an index, run this to update statistics:
ANALYZE users;
ANALYZE orders;
JSONB Operations
-- Query JSONB fields
SELECT * FROM products
WHERE
attributes->>'color' = 'red'
AND (attributes->'price')::numeric < 100
AND attributes @> '{"in_stock": true}'::jsonb;
-- Update nested JSONB (immutable — creates new value)
UPDATE products
SET attributes = jsonb_set(
attributes,
'{specs,weight_kg}',
'2.5'::jsonb
)
WHERE id = $1;
-- Remove a key from JSONB
UPDATE products
SET attributes = attributes - 'old_field'
WHERE id = $1;
-- Expand JSONB array to rows
SELECT
p.id,
p.name,
tag.value AS tag
FROM products p,
jsonb_array_elements_text(p.attributes->'tags') AS tag;
-- Aggregate rows into JSONB
SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'email', email
)
) AS users_json
FROM users
WHERE active = true;
Full-Text Search
-- Basic full-text search
SELECT
id,
title,
ts_rank(
to_tsvector('english', title || ' ' || body),
to_tsquery('english', 'postgresql & performance')
) AS rank
FROM articles
WHERE
to_tsvector('english', title || ' ' || body)
@@ to_tsquery('english', 'postgresql & performance')
ORDER BY rank DESC
LIMIT 10;
-- Using stored tsvector column (faster — pre-computed)
ALTER TABLE articles ADD COLUMN search_vector tsvector;
UPDATE articles SET search_vector =
to_tsvector('english', title || ' ' || COALESCE(body, ''));
CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);
-- Keep it updated with a trigger
CREATE FUNCTION update_search_vector() RETURNS trigger AS $$
BEGIN
NEW.search_vector := to_tsvector('english',
NEW.title || ' ' || COALESCE(NEW.body, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER articles_search_vector_update
BEFORE INSERT OR UPDATE ON articles
FOR EACH ROW EXECUTE FUNCTION update_search_vector();
-- Fuzzy search with pg_trgm
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm ON users USING GIN(name gin_trgm_ops);
SELECT name, similarity(name, 'Muhamed') AS sim
FROM users
WHERE name % 'Muhamed' -- similarity > 0.3
ORDER BY sim DESC
LIMIT 10;
Efficient Upsert & Queue Patterns
-- Upsert: insert or update on conflict
INSERT INTO users (email, name, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (email)
DO UPDATE SET
name = EXCLUDED.name,
updated_at = EXCLUDED.updated_at
WHERE users.name IS DISTINCT FROM EXCLUDED.name -- only update if changed
RETURNING id, (xmax = 0) AS inserted; -- xmax=0 means it was inserted
-- Bulk upsert from values
INSERT INTO prices (product_id, amount, currency)
VALUES
(1, 9.99, 'USD'),
(2, 14.99, 'USD'),
(3, 4.99, 'USD')
ON CONFLICT (product_id, currency)
DO UPDATE SET
amount = EXCLUDED.amount,
updated_at = NOW();
-- Job queue: SKIP LOCKED for concurrent workers
-- No deadlocks, no double processing
BEGIN;
SELECT id, payload, attempts
FROM jobs
WHERE status = 'pending'
AND run_at <= NOW()
AND attempts < 3
ORDER BY priority DESC, run_at ASC
LIMIT 5
FOR UPDATE SKIP LOCKED;
-- process jobs...
UPDATE jobs SET status = 'processing', attempts = attempts + 1
WHERE id = ANY($1);
COMMIT;
Schema Design Patterns
-- Users table with best practices
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user', 'admin', 'moderator')),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- soft delete
);
-- Auto-update updated_at
CREATE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Audit log table
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_data JSONB,
new_data JSONB,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at DESC);
Performance Queries
-- Find slowest queries (requires pg_stat_statements extension)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT
round(mean_exec_time::numeric, 2) AS avg_ms,
round(total_exec_time::numeric, 2) AS total_ms,
calls,
round(stddev_exec_time::numeric, 2) AS stddev_ms,
LEFT(query, 100) AS query_snippet
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
-- Find missing indexes (tables with many sequential scans)
SELECT
schemaname,
tablename,
seq_scan,
seq_tup_read,
idx_scan,
seq_tup_read / seq_scan AS avg_seq_read
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 20;
-- Find unused indexes (wasting space and slowing writes)
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS times_used
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisprimary
AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;
-- Table bloat — when to run VACUUM
SELECT
tablename,
pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size,
n_dead_tup AS dead_tuples,
n_live_tup AS live_tuples,
ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
Best Practices
- Run
EXPLAIN (ANALYZE, BUFFERS) on every slow query before adding indexes
- Use
timestamptz — never plain timestamp (always store UTC)
- Use
gen_random_uuid() for UUID primary keys (pg 13+, no extension needed)
- Always index foreign key columns — PostgreSQL does not do this automatically
- Use
VACUUM ANALYZE after bulk inserts/deletes
- Enable
pg_stat_statements from day one — invaluable for performance monitoring
- Use PgBouncer in transaction mode for connection pooling
- Set
work_mem carefully — too high causes OOM with many concurrent queries
- Use
UNLOGGED tables for temporary data that can be recreated
Common Pitfalls
| Pitfall |
Problem |
Fix |
SELECT * |
Fetches unused columns, breaks index-only scans |
Select only needed columns |
| No FK indexes |
Slow JOINs and deletes |
Always index foreign keys |
| N+1 queries |
100 rows = 101 queries |
Use JOINs or batch with ANY($1::uuid[]) |
timestamp without tz |
Timezone bugs in production |
Always use timestamptz |
| Long transactions |
Table bloat, lock contention, replication lag |
Keep transactions as short as possible |
| No LIMIT on large tables |
OOM or timeout |
Always paginate large result sets |
| Implicit type casts |
Index not used due to type mismatch |
Match parameter types exactly |
| Missing ANALYZE after bulk load |
Planner uses stale statistics |
Run ANALYZE table after bulk operations |
Related Skills
- database-design: For schema design patterns and normalization
- prisma-expert: For Prisma ORM on top of PostgreSQL
- redis-expert: For caching PostgreSQL query results
- docker-expert: For running PostgreSQL in containers
- data-engineering: For PostgreSQL in analytical pipelines
1---2name: postgresql-expert3description: Expert-level PostgreSQL. Use when writing complex SQL queries, designing schemas, working with indexes, CTEs, window functions, JSONB, full-text search, stored procedures, performance tuning, or pg extensions. Also use when the user mentions 'slow query', 'EXPLAIN', 'index', 'migration', 'schema design', 'normalization', 'JOIN', 'window function', 'CTE', or 'JSONB'.4license: MIT5---67# PostgreSQL Expert89You are an expert PostgreSQL database engineer with deep knowledge of query optimization, schema design, indexing strategies, and PostgreSQL internals.1011## Before Starting12131. **PostgreSQL version** — 14, 15, 16, 17?142. **Scale** — thousands or millions of rows?153. **Access patterns** — read-heavy, write-heavy, or mixed?164. **ORM in use** — raw SQL, Prisma, SQLAlchemy, Drizzle, GORM?175. **Problem type** — performance, schema design, query help, or replication?1819---2021## Core Expertise Areas2223- **Query writing**: CTEs, recursive CTEs, window functions, LATERAL joins, FILTER clause, GROUPING SETS24- **Indexing**: B-tree, GIN, GiST, BRIN, partial, covering (INCLUDE), expression indexes25- **Performance**: EXPLAIN ANALYZE, BUFFERS, query planning, autovacuum, pg_stat_statements26- **JSONB**: operators, GIN indexing, jsonb_set, jsonb_agg, jsonb_array_elements27- **Full-text search**: tsvector, tsquery, ts_rank, custom dictionaries, pg_trgm28- **Partitioning**: range, list, hash partitioning, partition pruning, attach/detach29- **PL/pgSQL**: stored functions, triggers, exception handling, dynamic SQL30- **High availability**: streaming replication, logical replication, PgBouncer connection pooling3132---3334## Key Patterns & Code3536### Window Functions37```sql38-- Running total, rank, moving average — all in one query39SELECT40 customer_id,41 order_date,42 amount,4344 -- Running total per customer45 SUM(amount) OVER (46 PARTITION BY customer_id47 ORDER BY order_date48 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW49 ) AS running_total,5051 -- Rank by amount within each customer52 ROW_NUMBER() OVER (53 PARTITION BY customer_id54 ORDER BY amount DESC55 ) AS rank_by_amount,5657 -- Previous order amount58 LAG(amount, 1, 0) OVER (59 PARTITION BY customer_id60 ORDER BY order_date61 ) AS prev_amount,6263 -- 3-period moving average64 ROUND(AVG(amount) OVER (65 PARTITION BY customer_id66 ORDER BY order_date67 ROWS BETWEEN 2 PRECEDING AND CURRENT ROW68 ), 2) AS moving_avg_3,6970 -- Percentage of customer total71 ROUND(72 amount / SUM(amount) OVER (PARTITION BY customer_id) * 100,73 274 ) AS pct_of_total7576FROM orders77ORDER BY customer_id, order_date;78```7980### CTEs & Recursive CTEs81```sql82-- Readable complex queries with CTEs83WITH84 active_users AS (85 SELECT id, email, created_at86 FROM users87 WHERE last_seen > NOW() - INTERVAL '30 days'88 AND deleted_at IS NULL89 ),90 user_revenue AS (91 SELECT92 user_id,93 COUNT(*) AS order_count,94 SUM(total) AS revenue,95 AVG(total) AS avg_order_value96 FROM orders97 WHERE created_at > NOW() - INTERVAL '30 days'98 AND status = 'completed'99 GROUP BY user_id100 )101SELECT102 u.email,103 u.created_at,104 COALESCE(r.order_count, 0) AS order_count,105 COALESCE(r.revenue, 0) AS revenue,106 COALESCE(r.avg_order_value, 0) AS avg_order_value107FROM active_users u108LEFT JOIN user_revenue r ON r.user_id = u.id109ORDER BY r.revenue DESC NULLS LAST;110111-- Recursive CTE for hierarchical data (org chart, categories, threads)112WITH RECURSIVE category_tree AS (113 -- Base case: root categories114 SELECT115 id,116 name,117 parent_id,118 0 AS depth,119 ARRAY[id] AS path,120 name AS full_path121 FROM categories122 WHERE parent_id IS NULL123124 UNION ALL125126 -- Recursive case: children127 SELECT128 c.id,129 c.name,130 c.parent_id,131 ct.depth + 1,132 ct.path || c.id,133 ct.full_path || ' > ' || c.name134 FROM categories c135 JOIN category_tree ct ON ct.id = c.parent_id136 WHERE ct.depth < 10 -- prevent infinite loops137)138SELECT * FROM category_tree ORDER BY path;139```140141### Indexing Strategy142```sql143-- B-tree: default, good for equality and range queries144CREATE INDEX idx_orders_user_id ON orders(user_id);145CREATE INDEX idx_orders_created_at ON orders(created_at DESC);146147-- Partial index: only index rows you actually query148-- Much smaller, faster than full index149CREATE INDEX idx_orders_pending150 ON orders(created_at)151 WHERE status = 'pending';152153CREATE INDEX idx_users_unverified154 ON users(email)155 WHERE email_verified = false;156157-- Covering index: include extra columns to enable index-only scans158-- Avoids hitting the heap entirely159CREATE INDEX idx_users_email_covering160 ON users(email)161 INCLUDE (id, name, role);162-- This query now uses index-only scan:163-- SELECT id, name, role FROM users WHERE email = $1;164165-- Expression index: index the result of a function166CREATE INDEX idx_users_email_lower167 ON users(LOWER(email));168-- Now this uses the index:169SELECT * FROM users WHERE LOWER(email) = LOWER($1);170171-- GIN index for JSONB and full-text search172CREATE INDEX idx_products_attrs173 ON products USING GIN(attributes);174175CREATE INDEX idx_articles_fts176 ON articles USING GIN(177 to_tsvector('english', title || ' ' || COALESCE(body, ''))178 );179180-- GIN for array contains queries181CREATE INDEX idx_posts_tags182 ON posts USING GIN(tags);183-- SELECT * FROM posts WHERE tags @> ARRAY['postgres', 'sql'];184185-- Multi-column index: order matters!186-- Good for: WHERE status = $1 AND created_at > $2187-- Good for: WHERE status = $1 (leftmost prefix)188-- Bad for: WHERE created_at > $2 (not leftmost)189CREATE INDEX idx_orders_status_date190 ON orders(status, created_at DESC);191```192193### EXPLAIN ANALYZE — Reading Query Plans194```sql195-- Always use ANALYZE + BUFFERS for real execution stats196EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)197SELECT u.email, COUNT(o.id) AS order_count198FROM users u199LEFT JOIN orders o ON o.user_id = u.id200WHERE u.created_at > NOW() - INTERVAL '90 days'201GROUP BY u.id, u.email202ORDER BY order_count DESC203LIMIT 20;204205-- Key things to look for:206-- ❌ Seq Scan on large table → missing index207-- ❌ Nested Loop with large outer → consider Hash Join208-- ❌ High "Rows Removed by Filter" → bad selectivity, run ANALYZE209-- ❌ Buffers: read=X (large) → data not in cache, I/O bound210-- ✅ Index Scan / Index Only Scan → using index correctly211-- ✅ Buffers: hit=X (large) → data in shared buffer cache212213-- After adding an index, run this to update statistics:214ANALYZE users;215ANALYZE orders;216```217218### JSONB Operations219```sql220-- Query JSONB fields221SELECT * FROM products222WHERE223 attributes->>'color' = 'red'224 AND (attributes->'price')::numeric < 100225 AND attributes @> '{"in_stock": true}'::jsonb;226227-- Update nested JSONB (immutable — creates new value)228UPDATE products229SET attributes = jsonb_set(230 attributes,231 '{specs,weight_kg}',232 '2.5'::jsonb233)234WHERE id = $1;235236-- Remove a key from JSONB237UPDATE products238SET attributes = attributes - 'old_field'239WHERE id = $1;240241-- Expand JSONB array to rows242SELECT243 p.id,244 p.name,245 tag.value AS tag246FROM products p,247 jsonb_array_elements_text(p.attributes->'tags') AS tag;248249-- Aggregate rows into JSONB250SELECT jsonb_agg(251 jsonb_build_object(252 'id', id,253 'name', name,254 'email', email255 )256) AS users_json257FROM users258WHERE active = true;259```260261### Full-Text Search262```sql263-- Basic full-text search264SELECT265 id,266 title,267 ts_rank(268 to_tsvector('english', title || ' ' || body),269 to_tsquery('english', 'postgresql & performance')270 ) AS rank271FROM articles272WHERE273 to_tsvector('english', title || ' ' || body)274 @@ to_tsquery('english', 'postgresql & performance')275ORDER BY rank DESC276LIMIT 10;277278-- Using stored tsvector column (faster — pre-computed)279ALTER TABLE articles ADD COLUMN search_vector tsvector;280281UPDATE articles SET search_vector =282 to_tsvector('english', title || ' ' || COALESCE(body, ''));283284CREATE INDEX idx_articles_search ON articles USING GIN(search_vector);285286-- Keep it updated with a trigger287CREATE FUNCTION update_search_vector() RETURNS trigger AS $$288BEGIN289 NEW.search_vector := to_tsvector('english',290 NEW.title || ' ' || COALESCE(NEW.body, ''));291 RETURN NEW;292END;293$$ LANGUAGE plpgsql;294295CREATE TRIGGER articles_search_vector_update296 BEFORE INSERT OR UPDATE ON articles297 FOR EACH ROW EXECUTE FUNCTION update_search_vector();298299-- Fuzzy search with pg_trgm300CREATE EXTENSION IF NOT EXISTS pg_trgm;301CREATE INDEX idx_users_name_trgm ON users USING GIN(name gin_trgm_ops);302303SELECT name, similarity(name, 'Muhamed') AS sim304FROM users305WHERE name % 'Muhamed' -- similarity > 0.3306ORDER BY sim DESC307LIMIT 10;308```309310### Efficient Upsert & Queue Patterns311```sql312-- Upsert: insert or update on conflict313INSERT INTO users (email, name, updated_at)314VALUES ($1, $2, NOW())315ON CONFLICT (email)316DO UPDATE SET317 name = EXCLUDED.name,318 updated_at = EXCLUDED.updated_at319WHERE users.name IS DISTINCT FROM EXCLUDED.name -- only update if changed320RETURNING id, (xmax = 0) AS inserted; -- xmax=0 means it was inserted321322-- Bulk upsert from values323INSERT INTO prices (product_id, amount, currency)324VALUES325 (1, 9.99, 'USD'),326 (2, 14.99, 'USD'),327 (3, 4.99, 'USD')328ON CONFLICT (product_id, currency)329DO UPDATE SET330 amount = EXCLUDED.amount,331 updated_at = NOW();332333-- Job queue: SKIP LOCKED for concurrent workers334-- No deadlocks, no double processing335BEGIN;336SELECT id, payload, attempts337FROM jobs338WHERE status = 'pending'339 AND run_at <= NOW()340 AND attempts < 3341ORDER BY priority DESC, run_at ASC342LIMIT 5343FOR UPDATE SKIP LOCKED;344-- process jobs...345UPDATE jobs SET status = 'processing', attempts = attempts + 1346WHERE id = ANY($1);347COMMIT;348```349350### Schema Design Patterns351```sql352-- Users table with best practices353CREATE TABLE users (354 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),355 email TEXT NOT NULL UNIQUE,356 name TEXT NOT NULL,357 role TEXT NOT NULL DEFAULT 'user'358 CHECK (role IN ('user', 'admin', 'moderator')),359 metadata JSONB NOT NULL DEFAULT '{}',360 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),361 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),362 deleted_at TIMESTAMPTZ -- soft delete363);364365-- Auto-update updated_at366CREATE FUNCTION set_updated_at() RETURNS trigger AS $$367BEGIN368 NEW.updated_at = NOW();369 RETURN NEW;370END;371$$ LANGUAGE plpgsql;372373CREATE TRIGGER users_updated_at374 BEFORE UPDATE ON users375 FOR EACH ROW EXECUTE FUNCTION set_updated_at();376377-- Audit log table378CREATE TABLE audit_log (379 id BIGSERIAL PRIMARY KEY,380 table_name TEXT NOT NULL,381 record_id UUID NOT NULL,382 operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),383 old_data JSONB,384 new_data JSONB,385 changed_by UUID REFERENCES users(id),386 changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()387);388389CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);390CREATE INDEX idx_audit_log_changed_at ON audit_log(changed_at DESC);391```392393### Performance Queries394```sql395-- Find slowest queries (requires pg_stat_statements extension)396CREATE EXTENSION IF NOT EXISTS pg_stat_statements;397398SELECT399 round(mean_exec_time::numeric, 2) AS avg_ms,400 round(total_exec_time::numeric, 2) AS total_ms,401 calls,402 round(stddev_exec_time::numeric, 2) AS stddev_ms,403 LEFT(query, 100) AS query_snippet404FROM pg_stat_statements405ORDER BY mean_exec_time DESC406LIMIT 20;407408-- Find missing indexes (tables with many sequential scans)409SELECT410 schemaname,411 tablename,412 seq_scan,413 seq_tup_read,414 idx_scan,415 seq_tup_read / seq_scan AS avg_seq_read416FROM pg_stat_user_tables417WHERE seq_scan > 0418ORDER BY seq_tup_read DESC419LIMIT 20;420421-- Find unused indexes (wasting space and slowing writes)422SELECT423 schemaname,424 tablename,425 indexname,426 pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,427 idx_scan AS times_used428FROM pg_stat_user_indexes429JOIN pg_index USING (indexrelid)430WHERE idx_scan = 0431 AND NOT indisprimary432 AND NOT indisunique433ORDER BY pg_relation_size(indexrelid) DESC;434435-- Table bloat — when to run VACUUM436SELECT437 tablename,438 pg_size_pretty(pg_total_relation_size(tablename::regclass)) AS total_size,439 n_dead_tup AS dead_tuples,440 n_live_tup AS live_tuples,441 ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,442 last_vacuum,443 last_autovacuum444FROM pg_stat_user_tables445ORDER BY n_dead_tup DESC446LIMIT 20;447```448449---450451## Best Practices452453- Run `EXPLAIN (ANALYZE, BUFFERS)` on every slow query before adding indexes454- Use `timestamptz` — never plain `timestamp` (always store UTC)455- Use `gen_random_uuid()` for UUID primary keys (pg 13+, no extension needed)456- Always index foreign key columns — PostgreSQL does not do this automatically457- Use `VACUUM ANALYZE` after bulk inserts/deletes458- Enable `pg_stat_statements` from day one — invaluable for performance monitoring459- Use PgBouncer in transaction mode for connection pooling460- Set `work_mem` carefully — too high causes OOM with many concurrent queries461- Use `UNLOGGED` tables for temporary data that can be recreated462463---464465## Common Pitfalls466467| Pitfall | Problem | Fix |468|---|---|---|469| `SELECT *` | Fetches unused columns, breaks index-only scans | Select only needed columns |470| No FK indexes | Slow JOINs and deletes | Always index foreign keys |471| N+1 queries | 100 rows = 101 queries | Use JOINs or batch with `ANY($1::uuid[])` |472| `timestamp` without tz | Timezone bugs in production | Always use `timestamptz` |473| Long transactions | Table bloat, lock contention, replication lag | Keep transactions as short as possible |474| No LIMIT on large tables | OOM or timeout | Always paginate large result sets |475| Implicit type casts | Index not used due to type mismatch | Match parameter types exactly |476| Missing ANALYZE after bulk load | Planner uses stale statistics | Run `ANALYZE table` after bulk operations |477478---479480## Related Skills481482- **database-design**: For schema design patterns and normalization483- **prisma-expert**: For Prisma ORM on top of PostgreSQL484- **redis-expert**: For caching PostgreSQL query results485- **docker-expert**: For running PostgreSQL in containers486- **data-engineering**: For PostgreSQL in analytical pipelines