Database Technologies Topic
PostgreSQL Advanced SQL & Performance
A. Query Performance
EXPLAIN
-
Purpose: Shows query execution plan without running the query.
-
Characteristics: Estimates costs, rows, and operations. Helps understand how PostgreSQL will execute query.
-
Use: Analyze query structure, identify missing indexes, understand join strategies.
sql
-- Basic explain
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- With formatting options
EXPLAIN (FORMAT JSON) SELECT * FROM users;
EXPLAIN (VERBOSE) SELECT * FROM users;
EXPLAIN (COSTS, TIMING, BUFFERS) SELECT * FROM users;
-- Understanding output:
-- Seq Scan = Full table scan (slow for large tables)
-- Index Scan = Using index (fast)
-- Nested Loop = For joins
-- Hash Join = For larger joins
-- Sort = Sorting operation
EXPLAIN ANALYZE
-
Purpose: Actually runs the query and shows real execution statistics.
-
Characteristics: Shows actual vs estimated rows, execution time. Runs the query.
-
Use: Get accurate performance metrics, identify bottlenecks.
sql
-- Run and analyze
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- With detailed statistics
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE created_at > '2024-01-01';
-- Output shows:
-- Planning time: Time to create plan
-- Execution time: Time to run query
-- Actual rows: Real rows returned vs estimated
-- Buffers: Disk I/O operations
Query Plans
-
Purpose: PostgreSQL's strategy for executing a query.
-
Characteristics: Tree structure of operations. Cost-based optimizer chooses best plan.
-
Use: Understand execution order, identify optimization opportunities.
sql
-- Complex query plan example
EXPLAIN
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2023-01-01'
GROUP BY u.id
HAVING COUNT(o.id) > 5
ORDER BY order_count DESC;
-- Common plan operations:
-- Seq Scan: Sequential scan (full table read)
-- Index Scan: Read via index
-- Index Only Scan: Everything from index (fastest)
-- Bitmap Heap Scan: Multiple index scans combined
-- Hash Join: Build hash table from one table
-- Nested Loop: For each row in outer table, scan inner
-- Merge Join: Both sides sorted, then merged
-- Sort: Sorting operation (expensive!)
-- Aggregate: Group by operations
Common Performance Pitfalls
sql
-- 1. Missing indexes
SELECT * FROM users WHERE lower(email) = 'test@example.com';
-- Solution: CREATE INDEX idx_users_lower_email ON users(lower(email));
-- 2. N+1 queries in application code
-- Instead of: Get user, then get orders for each user
-- Use: SELECT * FROM users JOIN orders ON users.id = orders.user_id;
-- 3. SELECT * (fetching unused columns)
SELECT * FROM users; -- BAD
SELECT id, name FROM users; -- BETTER
-- 4. Functions on indexed columns
SELECT * FROM users WHERE date_part('year', created_at) = 2024;
-- Solution: CREATE INDEX idx_users_created_year ON users(date_part('year', created_at));
-- 5. Large IN clauses
SELECT * FROM users WHERE id IN (1,2,3,...10000);
-- Better: Use JOIN with temporary table or WHERE EXISTS
-- 6. Not using LIMIT when appropriate
SELECT * FROM users ORDER BY created_at DESC; -- BAD
SELECT * FROM users ORDER BY created_at DESC LIMIT 100; -- BETTER
-- 7. Cross joins (cartesian products)
SELECT * FROM users, orders; -- BAD (returns users*orders rows)
SELECT * FROM users CROSS JOIN orders; -- EXPLICIT but same result
B. Index Types (Postgres-specific)
B-tree vs Hash
-
B-tree: Default index. Balanced tree structure. Supports range queries, sorting, equality. Most versatile.
-
Hash: Faster for exact equality matches only. No range queries. Less commonly used.
sql
-- B-tree (default, supports ranges)
CREATE INDEX idx_users_email ON users(email); -- Default is B-tree
-- Supports: =, <, >, <=, >=, BETWEEN, LIKE 'prefix%'
-- Hash (equality only, faster)
CREATE INDEX idx_users_id_hash ON users USING HASH (id);
-- Supports only: =
-- Example comparison
SELECT * FROM users WHERE email = 'test@example.com'; -- Both work
SELECT * FROM users WHERE email LIKE 'test%'; -- Only B-tree works
SELECT * FROM users WHERE email > 'test'; -- Only B-tree works
GIN indexes (JSONB, arrays)
-
Purpose: Generalized Inverted Index for composite values.
-
Characteristics: Optimized for "contains" operations on arrays, JSONB, full-text search.
-
Use: JSONB documents, array columns, full-text search vectors.
sql
-- For JSONB columns
CREATE INDEX idx_docs_data ON documents USING GIN (data);
-- Query with JSONB operators (uses index)
SELECT * FROM documents WHERE data @> '{"status": "active"}';
SELECT * FROM documents WHERE data ? 'email';
-- For array columns
CREATE INDEX idx_products_tags ON products USING GIN (tags);
SELECT * FROM products WHERE tags @> ARRAY['sale'];
SELECT * FROM products WHERE tags && ARRAY['sale', 'featured'];
-- For full-text search
CREATE INDEX idx_articles_content ON articles
USING GIN (to_tsvector('english', content));
GiST indexes
-
Purpose: Generalized Search Tree for complex data types.
-
Characteristics: Supports geometric data, full-text search, network addresses. Slower than B-tree for simple types.
-
Use: Geospatial data (PostGIS), range types, network addresses.
sql
-- With PostGIS extension (geospatial data)
CREATE INDEX idx_locations_geom ON locations USING GIST (geom);
SELECT * FROM locations
WHERE ST_DWithin(geom, ST_Point(0, 0), 1000);
-- For range types
CREATE TABLE reservations (
room_id INT,
during TSRANGE
);
CREATE INDEX idx_reservations_during ON reservations USING GIST (during);
SELECT * FROM reservations WHERE during && '[2024-01-15, 2024-01-20]';
BRIN indexes
-
Purpose: Block Range INdex for very large tables with natural sort order.
-
Characteristics: Very small size, fast for range scans on sorted data. Minimal maintenance.
-
Use: Time-series data, append-only tables, large tables with correlated physical order.
sql
-- For time-series data (created_at naturally sorted)
CREATE INDEX idx_logs_created ON logs USING BRIN (created_at);
SELECT * FROM logs
WHERE created_at BETWEEN '2024-01-01' AND '2024-01-31';
-- With pages_per_range tuning (default 128)
CREATE INDEX idx_sensors_timestamp ON sensor_data
USING BRIN (timestamp) WITH (pages_per_range = 32);
Partial indexes
-
Purpose: Index only subset of rows based on condition.
-
Characteristics: Smaller size, faster for queries matching condition. Doesn't index all rows.
-
Use: Frequently queried subsets, active/inactive records, sparse data.
sql
-- Index only active users
CREATE INDEX idx_users_active ON users(email) WHERE is_active = true;
SELECT * FROM users WHERE is_active = true AND email = 'test@example.com';
-- Index only recent orders
CREATE INDEX idx_orders_recent ON orders(user_id)
WHERE created_at > '2024-01-01';
-- Index only non-null values
CREATE INDEX idx_users_phone ON users(phone) WHERE phone IS NOT NULL;
Expression indexes
-
Purpose: Index on expression/function result, not column value.
-
Characteristics: Useful for case-insensitive searches, computed columns.
-
Use: Functional queries, computed columns, transformed data.
sql
-- Case-insensitive search
CREATE INDEX idx_users_lower_email ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = LOWER('Test@Example.com');
-- Date part extraction
CREATE INDEX idx_orders_year ON orders(EXTRACT(YEAR FROM created_at));
SELECT * FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2024;
-- Computed columns
CREATE INDEX idx_products_name_upper ON products(UPPER(name));
SELECT * FROM products WHERE UPPER(name) = 'LAPTOP';
C. Locks & Concurrency
Row-level locks
-
Purpose: Lock individual rows during modifications.
-
Characteristics: Minimal blocking, high concurrency. Automatic for UPDATE/DELETE.
-
Use: Allow concurrent access to different rows in same table.
sql
-- Automatic row locks on UPDATE
BEGIN;
UPDATE users SET balance = balance - 100 WHERE id = 1;
-- Row 1 is locked, other rows accessible
COMMIT;
-- Explicit row locking
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- Other transactions can't modify row 1 until we commit
-- FOR SHARE (weaker lock)
SELECT * FROM users WHERE id = 1 FOR SHARE;
-- Others can also SELECT FOR SHARE, but not FOR UPDATE
Table-level locks
-
Purpose: Lock entire table for DDL operations or bulk changes.
-
Characteristics: Blocking, low concurrency. Automatic for schema changes.
-
Use: Schema modifications, mass updates, consistency guarantees.
sql
-- Automatic table locks for DDL ALTER TABLE users ADD COLUMN last_login TIMESTAMPTZ; -- Table locked exclusively during operation -- Explicit table locking BEGIN; LOCK TABLE users IN SHARE MODE; -- Others can read but not write -- Bulk operations... COMMIT; -- Lock modes: -- ACCESS SHARE: Allow reads, block DROP (default for SELECT) -- ROW SHARE: Allow reads, block exclusive lock -- ROW EXCLUSIVE: Auto for UPDATE/DELETE/INSERT -- SHARE UPDATE EXCLUSIVE: Allow reads, block schema changes -- SHARE: Block writes -- SHARE ROW EXCLUSIVE: Block writes and most locks -- EXCLUSIVE: Block writes and reads -- ACCESS EXCLUSIVE: Block everything (for DDL)
Deadlocks
-
Purpose: When two transactions wait for each other's locks.
-
Characteristics: PostgreSQL detects and aborts one transaction.
-
Use: Avoid with consistent lock order, keep transactions short.
sql
-- Deadlock scenario: -- Transaction 1: BEGIN; UPDATE users SET balance = balance - 100 WHERE id = 1; -- Transaction 2: BEGIN; UPDATE users SET balance = balance + 100 WHERE id = 2; -- Transaction 1: UPDATE users SET balance = balance + 100 WHERE id = 2; -- Waits -- Transaction 2: UPDATE users SET balance = balance - 100 WHERE id = 1; -- DEADLOCK! -- PostgreSQL will: ERROR: deadlock detected -- Prevention: -- 1. Always lock in same order (e.g., always lower ID first) -- 2. Keep transactions short -- 3. Use row-level locks when possible -- 4. Use retry logic in application
SELECT FOR UPDATE
-
Purpose: Lock rows for update, preventing concurrent modifications.
-
Characteristics: Pessimistic locking. Blocks other writers.
-
Use: Prevent lost updates, ensure consistency in concurrent modifications.
sql
-- Banking transfer example BEGIN; -- Lock sender's row SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- Lock receiver's row SELECT * FROM accounts WHERE id = 2 FOR UPDATE; UPDATE accounts SET balance = balance - 100 WHERE id = 1; UPDATE accounts SET balance = balance + 100 WHERE id = 2; COMMIT; -- With NOWAIT (fail immediately if locked) SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT; -- SKIP LOCKED (skip locked rows, continue with others) SELECT * FROM jobs WHERE status = 'pending' FOR UPDATE SKIP LOCKED LIMIT 1; -- Useful for job queues
D. Window Functions (Advanced SQL)
OVER()
-
Purpose: Defines window/partition for window function calculations.
-
Characteristics: Doesn't group rows like GROUP BY, returns all rows.
-
Use: Calculate aggregates without collapsing rows.
sql
-- Basic window
SELECT
id,
amount,
SUM(amount) OVER() as total_amount -- Sum of all rows
FROM orders;
-- With PARTITION BY (like GROUP BY but keeps rows)
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER(PARTITION BY user_id) as user_total,
AVG(amount) OVER(PARTITION BY user_id) as user_avg
FROM orders;
-- With ORDER BY in window (cumulative sums)
SELECT
order_date,
amount,
SUM(amount) OVER(ORDER BY order_date) as running_total
FROM orders;
ROW_NUMBER
-
Purpose: Assign unique sequential number to each row in partition.
-
Characteristics: Always unique (no ties). Resets for each partition.
-
Use: Ranking, pagination, deduplication.
sql
-- Number orders per user
SELECT
user_id,
order_date,
amount,
ROW_NUMBER() OVER(
PARTITION BY user_id
ORDER BY order_date DESC
) as order_rank
FROM orders;
-- Get first order per user
WITH numbered_orders AS (
SELECT *,
ROW_NUMBER() OVER(
PARTITION BY user_id
ORDER BY order_date
) as rn
FROM orders
)
SELECT * FROM numbered_orders WHERE rn = 1;
-- Pagination alternative to LIMIT/OFFSET
SELECT * FROM (
SELECT *,
ROW_NUMBER() OVER(ORDER BY created_at) as rn
FROM users
) numbered
WHERE rn BETWEEN 11 AND 20;
RANK, DENSE_RANK
-
RANK: Ranking with gaps for ties (1,2,2,4).
-
DENSE_RANK: Ranking without gaps for ties (1,2,2,3).
-
Use: Competition rankings, percentile calculations.
sql
-- Sales ranking example
SELECT
salesperson_id,
revenue,
RANK() OVER(ORDER BY revenue DESC) as rank_with_gaps,
DENSE_RANK() OVER(ORDER BY revenue DESC) as dense_rank_no_gaps,
ROW_NUMBER() OVER(ORDER BY revenue DESC) as row_num_unique
FROM sales;
-- Top 3 performers by region
WITH ranked_sales AS (
SELECT *,
DENSE_RANK() OVER(
PARTITION BY region
ORDER BY revenue DESC
) as rank_in_region
FROM sales
)
SELECT * FROM ranked_sales WHERE rank_in_region <= 3;
LAG, LEAD
-
LAG: Access previous row's value.
-
LEAD: Access next row's value.
-
Use: Calculate differences, detect changes, time-series analysis.
sql
-- Daily revenue difference
SELECT
order_date,
daily_revenue,
LAG(daily_revenue) OVER(ORDER BY order_date) as prev_day,
daily_revenue - LAG(daily_revenue) OVER(ORDER BY order_date) as diff,
-- With default value for first row
LAG(daily_revenue, 1, 0) OVER(ORDER BY order_date) as prev_with_default
FROM daily_sales;
-- Next order date per user
SELECT
user_id,
order_date,
amount,
LEAD(order_date) OVER(
PARTITION BY user_id
ORDER BY order_date
) as next_order_date,
-- Days between orders
LEAD(order_date) OVER(
PARTITION BY user_id
ORDER BY order_date
) - order_date as days_between_orders
FROM orders;
Running totals
-
Purpose: Cumulative sum over ordered rows.
-
Characteristics: Requires ORDER BY in OVER() clause.
-
Use: Running balances, cumulative metrics, progress tracking.
sql
-- Running balance
SELECT
transaction_date,
amount,
SUM(amount) OVER(
ORDER BY transaction_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) as running_balance
FROM transactions;
-- Rolling average (last 7 days)
SELECT
date,
daily_sales,
AVG(daily_sales) OVER(
ORDER BY date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) as rolling_7day_avg
FROM sales_data;
-- Different window frames:
-- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (default with ORDER BY)
-- ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING (5-row window)
-- RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW (time-based)
-- Partitioned running total
SELECT
user_id,
order_date,
amount,
SUM(amount) OVER(
PARTITION BY user_id
ORDER BY order_date
) as user_running_total
FROM orders;
Key Performance Tips:
-
Index smartly: Use appropriate index type for data and queries
-
Analyze plans: Always check EXPLAIN ANALYZE for slow queries
-
Batch operations: Minimize round-trips with bulk operations
-
Connection pooling: Use PgBouncer or application-level pooling
-
Vacuum regularly: Prevent bloat with autovacuum tuning
-
Monitor locks: Use
pg_locksview to detect blocking -
Use window functions: Often more efficient than self-joins or subqueries
-
Keep statistics updated: Run ANALYZE after large data changes
Comments (0)
No comments yet. Be the first to comment!