By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
A hyper-practical, zero-fluff guide for real-world performance and cost efficiency
You’re a data analyst at a mid-sized e-commerce company. Your team runs a daily report that pulls customer orders, product details, and shipping statuses. The query looks like this:
SELECT * FROM orders JOIN customers USING (customer_id) JOIN products USING (product_id) WHERE order_date >= '2024-01-01';
At first, it works fine. But as the company grows, the orders table swells to 50 million rows, and the report starts timing out. Your database admin (DBA) sends you a Slack message:
orders
"Your query is scanning 12GB of data and killing our read replicas. Fix it or we’re throttling your access."
This guide teaches you three critical optimizations to prevent this nightmare: 1. Avoiding SELECT * → Reduces I/O, network traffic, and memory usage.2. Index-Only Scans → Lets the database fetch data without touching the table (like reading a book’s index instead of flipping every page).3. Preventing Table Bloat → Stops your tables from ballooning in size due to dead rows, slowing down every query.
SELECT *
Why this matters in production:- Cost: Cloud databases (e.g., AWS RDS, BigQuery) charge by data scanned. SELECT * on a 100GB table? That’s a $500 bill.- Performance: A query that takes 30 seconds with SELECT * might take 300ms with proper indexing.- Reliability: Bloated tables cause lock contention, leading to timeouts and failed transactions.- Scalability: If your query scans 10x more data than needed, it won’t scale when traffic grows.
Real-world scenario:You inherit a legacy analytics dashboard that runs SELECT * FROM sales on a 200GB table. The dashboard loads in 15 seconds for you, but times out for users in Europe (higher latency). Your boss wants it fixed today.
SELECT * FROM sales
order_id
total_amount
orders(order_date, customer_id)
sql SELECT customer_id FROM orders WHERE order_date = '2024-01-01';
sql SELECT customer_id, order_date, total_amount FROM orders WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31';
sql CREATE INDEX idx_orders_covering ON orders(order_date) INCLUDE (customer_id, total_amount);
VACUUM
VACUUM FULL orders
autovacuum = off
UPDATE
orders.status
fillfactor = 80
fillfactor = 70
psql
We’ll simulate a real-world scenario where a table grows uncontrollably due to updates and deletes.
-- Create a table with 1M rows CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INT NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, status VARCHAR(20) NOT NULL, notes TEXT ); -- Insert 1M rows (run in batches if needed) INSERT INTO orders (customer_id, order_date, total_amount, status, notes) SELECT (random() * 10000)::INT, '2023-01-01'::DATE + (random() * 365)::INT, (random() * 1000)::DECIMAL(10, 2), CASE WHEN random() < 0.5 THEN 'shipped' ELSE 'pending' END, md5(random()::TEXT) FROM generate_series(1, 1000000);
Check how much bloat exists in the table.
-- Check table size and bloat SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total_size, pg_size_pretty(pg_table_size('orders')) AS table_size, pg_size_pretty(pg_indexes_size('orders')) AS indexes_size, n_dead_tup AS dead_rows, n_live_tup AS live_rows, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_percentage FROM pg_stat_user_tables WHERE relname = 'orders';
Expected output:
total_size | table_size | indexes_size | dead_rows | live_rows | bloat_percentage ------------+------------+--------------+-----------+-----------+------------------ 120 MB | 80 MB | 40 MB | 0 | 1000000 | 0.00
(No bloat yet—we’ll create some in the next step.)
Run these commands to artificially bloat the table:
-- Update 500K rows (creates dead rows) UPDATE orders SET status = 'cancelled' WHERE order_id % 2 = 0; -- Delete 200K rows (creates more dead rows) DELETE FROM orders WHERE order_id % 5 = 0; -- Check bloat again SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total_size, n_dead_tup AS dead_rows, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_percentage FROM pg_stat_user_tables WHERE relname = 'orders';
total_size | dead_rows | bloat_percentage ------------+-----------+------------------ 180 MB | 700000 | 41.18
Now the table is 41% bloat! The physical size grew from 120MB → 180MB, even though we only have 300K live rows (1M - 500K updates - 200K deletes).
Run VACUUM to reclaim space:
-- Standard VACUUM (reclaims space but doesn't shrink the file) VACUUM orders; -- Check bloat again SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total_size, n_dead_tup AS dead_rows, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_percentage FROM pg_stat_user_tables WHERE relname = 'orders';
total_size | dead_rows | bloat_percentage ------------+-----------+------------------ 180 MB | 0 | 0.00
VACUUM FULL
VACUUM FULL orders;
Now the table size drops to ~60MB (close to the original size).
Let’s say your team runs this query 100 times a day:
-- Bad: SELECT * (scans the whole table) EXPLAIN ANALYZE SELECT * FROM orders WHERE order_date BETWEEN '2023-06-01' AND '2023-06-30';
Output:
Seq Scan on orders (cost=0.00..22965.00 rows=83333 width=56) (actual time=0.012..120.456 rows=83333 loops=1) Filter: ((order_date >= '2023-06-01'::date) AND (order_date <= '2023-06-30'::date)) Planning Time: 0.123 ms Execution Time: 150.234 ms
Fix: Use a covering index + explicit columns:
-- Create a covering index CREATE INDEX idx_orders_covering ON orders(order_date) INCLUDE (customer_id, total_amount, status); -- Rewrite the query to avoid SELECT * EXPLAIN ANALYZE SELECT customer_id, total_amount, status FROM orders WHERE order_date BETWEEN '2023-06-01' AND '2023-06-30';
Index Only Scan using idx_orders_covering on orders (cost=0.29..2800.29 rows=83333 width=20) (actual time=0.023..10.123 rows=83333 loops=1) Index Cond: ((order_date >= '2023-06-01'::date) AND (order_date <= '2023-06-30'::date)) Heap Fetches: 0 Planning Time: 0.123 ms Execution Time: 15.456 ms
Heap Fetches: 0
Set fillfactor to reduce row migrations during updates:
fillfactor
-- Recreate the table with fillfactor=80 (leaves 20% space for updates) DROP TABLE orders; CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INT NOT NULL, order_date DATE NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, status VARCHAR(20) NOT NULL, notes TEXT ) WITH (fillfactor = 80); -- Re-insert data INSERT INTO orders (customer_id, order_date, total_amount, status, notes) SELECT (random() * 10000)::INT, '2023-01-01'::DATE + (random() * 365)::INT, (random() * 1000)::DECIMAL(10, 2), CASE WHEN random() < 0.5 THEN 'shipped' ELSE 'pending' END, md5(random()::TEXT) FROM generate_series(1, 1000000); -- Update 500K rows again UPDATE orders SET status = 'cancelled' WHERE order_id % 2 = 0; -- Check bloat SELECT pg_size_pretty(pg_total_relation_size('orders')) AS total_size, n_dead_tup AS dead_rows, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_percentage FROM pg_stat_user_tables WHERE relname = 'orders';
total_size | dead_rows | bloat_percentage ------------+-----------+------------------ 130 MB | 500000 | 33.33
fillfactor=80
✅ Do:- Always specify columns in SELECT. ```sql -- Good SELECT order_id, customer_id, total_amount FROM orders;
SELECT
-- Bad SELECT * FROM orders; - Use `EXPLAIN ANALYZE` to check if your query scans unnecessary data.- Add `LIMIT` when debugging to avoid fetching millions of rows.sql SELECT * FROM orders LIMIT 10; -- Safe for testing ```
- Use `EXPLAIN ANALYZE` to check if your query scans unnecessary data.- Add `LIMIT` when debugging to avoid fetching millions of rows.
❌ Don’t:- Use SELECT * in ETL pipelines (you’ll process 10x more data than needed).- Assume SELECT * is "simpler"—it’s technical debt.
✅ Do:- Create covering indexes for frequent queries. sql CREATE INDEX idx_orders_covering ON orders(order_date) INCLUDE (customer_id, total_amount); - Check EXPLAIN for Index Only Scan (means the query never touches the table).- Use pg_stat_user_indexes to find unused indexes (drop them to reduce write overhead). sql SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE schemaname = 'public' AND idx_scan < 10;
EXPLAIN
Index Only Scan
pg_stat_user_indexes
sql SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE schemaname = 'public' AND idx_scan < 10;
❌ Don’t:- Create indexes without INCLUDE if you need extra columns (forces table access).- Assume all indexes are good—unused indexes slow down INSERT/UPDATE/DELETE.
INCLUDE
INSERT/UPDATE/DELETE
✅ Do:- Enable autovacuum (PostgreSQL default, but check settings). sql SHOW autovacuum; -- Should be 'on' - Set fillfactor for write-heavy tables (e.g., fillfactor=70 for tables with frequent updates).- Monitor bloat with: sql SELECT schemaname, relname, n_dead_tup, n_live_tup, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_pct FROM pg_stat_user_tables WHERE n_dead_tup > 0 ORDER BY bloat_pct DESC; - Schedule VACUUM during low-traffic periods (e.g., nightly).
sql SHOW autovacuum; -- Should be 'on'
fillfactor=70
sql SELECT schemaname, relname, n_dead_tup, n_live_tup, round(n_dead_tup::numeric / (n_live_tup + n_dead_tup) * 100, 2) AS bloat_pct FROM pg_stat_user_tables WHERE n_dead_tup > 0 ORDER BY bloat_pct DESC;
❌ Don’t:- Disable autovacuum (tables will bloat uncontrollably).- Run VACUUM FULL during peak hours (locks the table).- Ignore bloat—it compounds over time and slows down every query.
order_date
sql COMMENT ON INDEX idx_orders_covering IS 'Covering index for order_date range queries';
idx_table_column
uq_table_column
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.