Query Tuning Techniques (Part 2)
Introduction
Continuing from the previous article, here are other Query Tuning techniques based on common errors encountered when querying data and the process you can apply:
Optimizing JOIN Statements
- Ensure columns used for JOIN have Indexes (usually
Foreign Keyconnecting toPrimary Key).- If Postgres uses a
Nested Loop Joinwith a matching Index, it speeds up finding rows needed for comparison during JOIN - If using a
Merge Join, it skips the step of sorting two lists before joining
- If Postgres uses a
- Try filtering data using
WHEREbeforehand or directly inside theONcondition of the JOIN to reduce the row count matching between tables.- The result of using JOIN is multiplication:
A Join B = A x B - Therefore, if conditions can minimize lists A and B, the generated dataset will be as small as possible
- The result of using JOIN is multiplication:
Joining Too Many Tables
- By default, if joining more than
12 tables, Postgres cannot analyze and find the optimal JOIN execution plan because the number of combinations to process is too large - At this point, it activates the
Genetic Query Optimizerto attempt finding the best possible randomized solution, rather than the globally most efficient solution - Solutions
- Break down the JOIN operation into smaller parts to produce intermediate results using
TEMP TABLE or CTE - Having fewer tables to JOIN allows Postgres to analyze and operate more efficiently
- Break down the JOIN operation into smaller parts to produce intermediate results using
Using LIKE
- If you want to search for asymmetrical strings using
LIKEin the format{Text}%, theB-Tree Indexcan work - However, if using
LIKE/ILIKEwithwildcards (%)as follows- With
%{Text}, no results will be found - With
%{Text}%, results are found, but theIndexwill not be utilized
- With
- Because the
B-Tree Indexmust perform aFull Table Scan, it cannot use the Index to search for starting characters. - The solution is to replace it with
GIN/GiST Indexto utilizeFull-Text SearchorTrigram, which offer good support for both case-sensitive and case-insensitiveLIKE/ILIKEsearches
Using OR
If Indexes are already created on columns, we divide into the following scenarios
- Using
ORon the same column- In this scenario, whether using
ORorIN, Postgres rewrites the query to use OR and processes it similarly - However, you should still prefer
INto keep the query concise and clear
- In this scenario, whether using
- Using
ORacross multiple columns- In this scenario, if individual Indexes exist on each column (separate Indexes per column, not a
Composite Index), Postgres can useBitmap Index Scan - Because
ORis used instead ofANDacross multiple columns, aComposite Indexdoes not work and it must combine with aHeap Scanto recheck conditions rather than relying purely on the Index - If the retrieved data volume is too large, Postgres may switch to a Seq Scan instead of using the Index
- The solution here is to replace it with
UNION/UNION ALLfor each individual column to use the Index effectively - Solution
- First, create Indexes for columns used with OR, as this keeps the query simple and readable
- Later, if the retrieved data volume increases significantly during usage, causing Postgres to switch to Seq Scan instead of Bitmap Index Scan
- Then you should switch to
UNION/UNION ALL
- Example
- Original query:
SELECT * FROM users WHERE status = 'active' OR age > 60 - Rewritten with
UNION ALL:SELECT * FROM users WHERE status = 'active' UNION ALL SELECT* FROM users WHERE age > 60 AND (status != 'active' OR status IS NULL)
- Original query:
- In this scenario, if individual Indexes exist on each column (separate Indexes per column, not a
Overusing DISTINCT / UNION
- When using
UNIONorDISTINCTto eliminate duplicate rows, Postgres performs a heavy operation grouping and sorting the entire dataset (Unique / HashAggregate). - If you are certain the data between two sets does not overlap or you accept duplicates, use
UNION ALL. The planner will simply append the two datasets without extra overhead for sorting and deduplication.
For example
SELECT user_id FROM order_logs WHERE action = 'click'
UNION
SELECT user_id FROM order_logs WHERE action = 'view';
Can be replaced with
SELECT user_id FROM order_logs WHERE action = 'click'
UNION ALL
SELECT user_id FROM order_logs WHERE action = 'view';
Using NOT IN with Subqueries
- When using
NOT IN, the Postgres planner often selects an extremely slowSequential Scanexecution plan. - Moreover, if the subquery returns even a single
NULLvalue, the entire statement yields an empty result - You should switch to
NOT EXISTS. It is safe with NULL values and enables the planner to use anAnti-Joinoptimized by Indexes.- Example query:
SELECT * FROM employees WHERE id NOT IN (SELECT manager_id FROM departments) - Should be changed to:
SELECT * FROM employees e WHERE NOT EXISTS (SELECT 1 FROM departments d WHERE d.manager_id = e.id)
- Example query:
Cleaning and Updating Data
- Postgres uses
MVCC. When performingUPDATE or DELETE, old data is not immediately removed but converted intoDead Tuples - Although
Auto Vacuumautomatically cleans up Dead Tuples when reaching specific thresholds, various reasons can delay this process, leading to inaccurate Postgres statistics - This prevents the
Plannerfrom choosing optimal paths, such as deciding betweenIndexorSeq Scan - Additionally, excessive Dead Tuples consume disk volume and impact performance during Seq Scans (even though these rows are skipped, they must still be loaded to check for Dead Tuples)
- As a solution, you can proactively execute manual commands
ANALYZE table_name: updates statistics so the Planner has enough information to process queries effectivelyVACUUM table_name: checks that Dead Tuples are no longer in use anywhere, then updates theFree Space Mapto allow subsequentINSERToperations to overwrite those Dead TuplesVACUUM ANALYZE table_name: combines both commands above
Detail
Create tables as follows
CREATE TABLE customers (
customer_id SERIAL PRIMARY KEY,
full_name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INT REFERENCES customers(customer_id) ON DELETE CASCADE,
order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
total_amount DECIMAL(12,2) DEFAULT 0.00,
status VARCHAR(20) DEFAULT 'COMPLETED'
);
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
product_name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(12,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
item_id SERIAL PRIMARY KEY,
order_id INT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_id INT REFERENCES products(product_id),
quantity INT NOT NULL CHECK (quantity > 0),
unit_price DECIMAL(12,2) NOT NULL
);
Optimizing JOIN Statements
-- Query 1
SELECT
c.full_name,
o.order_id,
o.order_date,
p.product_name,
oi.quantity,
oi.unit_price
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id;
-- Query 2
SELECT
c.full_name,
o.order_id,
o.order_date,
p.product_name,
oi.quantity,
oi.unit_price
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
WHERE o.total_amount > 40000
- These two queries differ only in the
WHEREcondition. The Postgres Planner will select the optimal solution when performing aJOIN - It selects the smallest tables to join first, so adding more specific conditions reduces dataset size, improves Index efficiency and executes queries faster
- Query 1 execution process:
Seq Scanonordersto create aHash TableSeq Scanonorder_itemsusingHash Inner Jointo join withordersSeq Scanoncustomersto create aHash Tableand useHash Inner Joinwith the previous resultSeq Scanonproductsto create aHash Tableand useHash Inner Joinwith the previous result
- Query 2 execution process:
- Run
Seq ScanonorderswithFilter: (total_amount > '40000'::numeric)to create a Hash Table - Run
Seq Scanonorder_itemsand useHash Inner Joinwithorders Index Scanusingcustomers_pkeyoncustomersand useNested Loop Inner Joinwith the previous resultIndex Scanusingproducts_pkeyonproductsand useNested Loop Inner Joinwith the previous result
Joining Too Many Tables
WITH OrderTotals AS (
SELECT
o.customer_id,
SUM(oi.quantity * oi.unit_price) AS total_spent
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
GROUP BY o.customer_id
)
SELECT
c.full_name,
ot.total_spent
FROM customers c
JOIN OrderTotals ot ON c.customer_id = ot.customer_id;
This query calculates the total expenditure per customer using a CTE to break down table JOIN operations
OrderTotalsrepresents the result of joining order tables to calculate total expenditure by customer_id- Afterward,
OrderTotalsis joined with thecustomerstable to retrievefull_name - If you prefer using a
TEMP TABLE, refer to detailed instructions here
Using LIKE
First, use a B-tree Index as follows
CREATE INDEX idx_products_name_btree_pattern ON products(product_name varchar_pattern_ops);
SELECT * FROM products WHERE product_name LIKE 'Logi%';
SELECT * FROM products WHERE product_name LIKE 'logi%';
SELECT * FROM products WHERE product_name LIKE '%853';
SELECT * FROM products WHERE product_name LIKE '%tech%';
SELECT * FROM products WHERE product_name ILIKE 'logi%';
SELECT * FROM products WHERE product_name ILIKE '%853';
SELECT * FROM products WHERE product_name ILIKE '%tech%';
- Using
varchar_pattern_opscreates aB-Tree Indexsupporting bothLIKEandILIKE - The results will be as follows
LIKELogi%: Can use Bitmap Index Scan, the only format supported byB-treelogi%: No results found due to case-sensitivity inLIKE%853 and %tech%: Yields results, but theIndexis inactive, falling back to Seq Scan
ILIKEforlogi%, %tech, %tech%: Yields results, but theIndexis inactive
Next, let us examine Full-Text Search and Trigram
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_product_name_gin ON products USING GIN(to_tsvector('simple', product_name));
CREATE INDEX idx_products_name_gin_trgm ON products USING GIN(product_name gin_trgm_ops);
-- Query 1
SELECT * FROM products WHERE to_tsvector('simple', product_name) @@ to_tsquery('simple', 'Apple & Phone');
-- Query 2
SELECT * FROM products WHERE product_name LIKE 'Logi%';
SELECT * FROM products WHERE product_name LIKE '%Mouse 853';
SELECT * FROM products WHERE product_name LIKE '%Wireless%';
SELECT * FROM products WHERE product_name LIKE 'Lo%';
SELECT * FROM products WHERE product_name LIKE '%53';
SELECT * FROM products WHERE product_name LIKE '%el%';
SELECT * FROM products WHERE product_name ILIKE 'logi%';
SELECT * FROM products WHERE product_name ILIKE '%mOuSe 853';
SELECT * FROM products WHERE product_name ILIKE '%WIRELESS%';
- Enable the
pg_trgmextension prior to usage idx_products_product_name_ginis a GIN Index forFull-Text Search(tsvector)idx_products_name_gin_trgmis a GIN Index forTrigram Search- Query 1: Uses
Full-Text Searchto find aproduct_namecontaining bothAppleandPhone - Query 2: These queries trigger the
idx_products_name_gin_trgmIndex LIKE: Supports case-sensitive searchesLogi%: Can use Bitmap Index Scan, exact casing required%Mouse 853: Uses Index to find content ending inMouse 853%Wireless%: Uses Index to find content containingWireless- Although using
gin_trgm_opssplits strings into 3-character items, it still works with fewer characters likeLo%,%53, or%el%by automatically adding padding spaces before and after to reach 3 characters for the search."
ILIKE: Supports case-insensitive searches, functioning like LIKE while matching lowercase entries
Using OR
SELECT * FROM products WHERE product_id IN(10, 11, 12)
SELECT * FROM products WHERE product_id = 10 OR product_id = 11 OR product_id = 12
Using EXPLAIN shows that both queries are processed identically, but using IN is more concise and readable than OR
To use OR across multiple columns, proceed as follows
CREATE INDEX idx_orders_order_date ON orders(order_date)
CREATE INDEX idx_orders_total_amount ON orders(total_amount)
-- Query 1
SELECT * FROM orders WHERE order_date > '2026-07-01' OR total_amount > 40000
-- Query 2
SELECT * FROM orders WHERE order_date > '2026-07-01'
UNION
SELECT * FROM orders WHERE total_amount > 40000;
-- Query 3
SELECT * FROM orders WHERE order_date > '2026-07-01'
UNION ALL
SELECT * FROM orders WHERE total_amount > 40000 AND (order_date <= '2026-07-01' OR order_date IS NULL);
First, create Indexes for required columns order_date and total_amount. All three queries using OR, UNION, UNION ALL return identical results, but their underlying executions differ
- Query 1: Postgres uses a Bitmap Index Scan on each column followed by a
Bitmap Heap Scanto recheck conditions in the Heap (Recheck Cond: ((order_date > '2026-07-01 00:00:00'::timestamp without time zone) OR (total_amount > '40000'::numeric))) - Query 2: The execution splits into two queries
Bitmap Index Scanusingidx_orders_order_dateIndex Scanusingidx_orders_total_amount- Then
Appendcombines rows into the result list, accompanied by anAggregatestep for deduplication
- Query 3: Operates similarly to
UNIONbut omits deduplication for better performance. Additional boundary conditionstotal_amount > 40000 AND (order_date <= '2026-07-01' OR order_date IS NULL)ensure accuracy
Overusing DISTINCT / UNION
-- Query 1
SELECT DISTINCT customer_id FROM orders WHERE customer_id IS NOT NULL;
-- Query 2
SELECT customer_id FROM orders WHERE customer_id IS NOT NULL
UNION
SELECT customer_id FROM customers;
-- Query 3
SELECT customer_id FROM orders GROUP BY customer_id
UNION ALL
SELECT customer_id FROM customers WHERE customer_id NOT IN (SELECT customer_id FROM orders WHERE customer_id IS NOT NULL);
These three queries produce identical results via different execution methods
- Query 1: Uses
DISTINCTto remove duplicatecustomer_identries - Query 2: Fetches
customer_idfromorders, merges withcustomer_idfromcustomersand eliminates duplicates - Query 3: Merges customers who placed orders with those who have not. Using
UNION ALLavoids deduplication overhead, so careful query constraints are required
Using NOT IN with Subqueries
-- Query 1
SELECT o.*
FROM orders o
WHERE o.order_id NOT IN (
SELECT oi.order_id
FROM order_items oi
);
-- Query 2
SELECT o.*
FROM orders o
WHERE o.order_id NOT IN (
SELECT oi.order_id
FROM order_items oi
WHERE oi.order_id IS NOT NULL
);
-- Query 3
SELECT o.*
FROM orders o
WHERE NOT EXISTS (
SELECT 1
FROM order_items oi
WHERE oi.order_id = o.order_id
);
These queries retrieve orders containing product items using NOT IN and NOT EXISTS
- Query 1: Standard
NOT INusage. If the subquery yields a NULL value, the query returns no rows - Query 2: Since
NOT INstruggles with NULL values, addingIS NOT NULLensures the subquery returns no NULLs. This produces correct output but remains inefficient due to lack of Index utilization - Query 3: Using
NOT EXISTSis more efficient because it leveragesIndexandHash Anti Joinscans
Cleaning and Updating Data
-- Statement 1
UPDATE order_items SET unit_price = unit_price + 1 WHERE item_id < 100
-- Statement 2
SELECT
relname AS table_name,
n_live_tup AS live_tuples,
n_dead_tup AS dead_tuples,
ROUND(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_percent,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'order_items';
VACUUM order_items
- Statement 1: Dead Tuples are generated when executing
UPDATE/DELETE. Try updating rows and run Statement 2 to observeDead Tuplecounts - Statement 2: Displays
Live Tuple, Dead Tuplemetrics and percentages for a specific table - You can execute VACUUM commands to reclaim Dead Tuples and verify the metrics again
Happy coding!
Comments
Post a Comment