Query Tuning Techniques
Introduction
These are Query Tuning techniques based on common errors encountered when querying data and the process you can apply:
Avoid Using SELECT *
- When you use queries like this
SELECT * FROM users WHERE id = 100
SELECT username FROM users WHERE id = 100
- If there is no
Index,Postgreswill have to useHeap Scanto load the corresponding entire data page intoRAM - At this point, whether you use
SELECT *orSELECT name,Postgreshandles it the same way - But the difference starts here, if you only
SELECTthe necessary columns,Postgresonly needs to process those columns without spending additional CPU cost to process all columns as withSELECT *- If any of the requested columns are too large and stored in the
TOAST Table, it incurs additionalI/Oand data decompression costs - At the same time, if you deploy a
Web Serverto respond with this data toFE, larger amounts of data will consume more network bandwidth,RAMto parseJSONand take longer to process
- If any of the requested columns are too large and stored in the
- Solution
- Only retrieve the specific columns needed
- Add appropriate
Indexesfor the required columns
Use Appropriate Indexes
- If a query is executed frequently, add suitable
Indexesto the columns, in which case- Postgres can use
Index Only Scaninstead ofSeq Scan - If the required columns are not all in the
Index, Postgres will still rely on conditions to useIndex Scan, scanning theIndexfirst and then usingTIDto fetch data from theHeap, which is still much faster than a standardSeq Scan
- Postgres can use
- To let
Postgresdecide to use anIndexinstead ofSeq Scan, it relies on several factors such as- Whether the number of rows to retrieve is small or large for the
Optimizerto calculate costs and choose the optimal method - Whether the created
Indexis included in condition clauses likeWHERE, JOIN, ORDER BY and GROUP BY
- Whether the number of rows to retrieve is small or large for the
- You can read articles about different types of
Indexto understand their characteristics and use them appropriately
Use Functions on Indexed Columns
- How you create an
Indexdictates how you must query with the exact condition soPostgrescan utilize the correspondingIndex - Therefore, using a
Functionon an indexed column alters the value of that column, preventing theIndexfrom working- For example, using
SELECT * FROM orders WHERE YEAR(created_at) = 2000 - Even if
created_athas anIndex, it cannot be used
- For example, using
- Solution
- Keep the original column and shift the logic to the
Literal Valueside as followsSELECT * FROM orders WHERE created_at >= '2000-01-01' AND created_at <= '2000-12-31' - You can reverse it by applying the
Functionto the value side so theIndexstill works likeWHERE name = lower('Your Name') - Use an
Expression Index
- Keep the original column and shift the logic to the
Direct Calculations on Condition Columns
- This is similar to using a
Functionon a comparison column,Postgrescannot calculate the value to match against theIndexdata - The solution is to move all calculations to the constant side
- For example,
SELECT * FROM products WHERE price * 0.9 < 100 - Change it to
SELECT * FROM products WHERE price < (100 / 0.9)
- For example,
Use ORDER BY or LIMIT
- Using
ORDER BY- Using
ORDER BYon a column without anIndexforces aSeq Scanfollowed by aSorton that dataset - If the column has an
Index, it breaks down into several query scenariosSELECT created_at FROM posts ORDER BY created_at- If you only need columns present in the
Index,Postgresjust usesIndex Only Scanto retrieve data - Whether sorting
ASCorDESC, it works as expected
- If you only need columns present in the
SELECT * FROM posts ORDER BY created_at- When fetching all columns or other non-indexed columns, Postgres calculates the
Costto choose betweenIndex ScanandSeq Scan - Using
Index Scanmeans scanning theIndexfirst and usingRandom I/Oto read data in theHeap - Using
Seq Scanmeans fetching data in theHeapand performing aSorton the column - Depending on the amount of data to fetch,
Postgreschooses the appropriate solution
- When fetching all columns or other non-indexed columns, Postgres calculates the
- Using
- Using
LIMIT: points to keep in mind- Even if you only SELECT columns in the Index without using ORDER BY or WHERE, Postgres will choose
Seq Scan - Because without additional constraints, reading sequentially from the
Heapto get the first few rows is faster and simpler than using anIndex
- Even if you only SELECT columns in the Index without using ORDER BY or WHERE, Postgres will choose
- Using
ORDER BYandLIMIT- Without an
Index, it is the worst-case scenario because it performs aSeq Scanto load all data, sorts it and then usesLIMITto get the specified row count, discarding unused rows and wastingSortcost - If an
Indexexists, it breaks down into the following scenarios- If retrieving only columns in the Index, it uses Index Only Scan
- If retrieving additional columns outside the Index
- If
LIMITis not too large and can be processed inwork_mem, Postgres still choosesIndex Scan(scanning theIndexfirst, thenHeap Scan) - If
LIMITis too large, Postgres cost estimation may determineSeq Scan+Sortis more efficient
- If
- Without an
Implicit Type Mismatch
We categorize this into the following cases
Column Transformation- Occurs when you use a Function to change the data type
- Using a Function on a column disables the Index as discussed earlier
- The solution is similar: shift logic, use a Function on the value, or create an
Expression Index
Implicit Casting- Occurs when comparing 2 different data types, forcing Postgres to automatically cast one of them
- If Postgres automatically casts the Value side, the Index still works
- If Postgres automatically casts the Column side, the Index cannot work
- This depends on whether the 2 data types can be cast for comparison and their type precedence
- Occurs when comparing 2 different data types, forcing Postgres to automatically cast one of them
Detail
Create Tables and Indexes as follows
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE,
email VARCHAR(100) UNIQUE,
phone VARCHAR(15) UNIQUE,
balance NUMERIC(10,2),
dob DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_balance ON users (balance);
CREATE INDEX idx_users_created_at ON users (created_at);
Then seed data like this
INSERT INTO users (username, email, phone, balance, dob, created_at)
SELECT
'user_' || gs.i AS username,
'user_' || gs.i || '@example.com' AS email,
'09' || LPAD((FLOOR(RANDOM() * 100000000))::text, 8, '0') AS phone,
ROUND((RANDOM() * 5000)::numeric, 2) AS balance,
'1970-01-01'::DATE + (RANDOM() * (DATE '2005-12-31' - DATE '1970-01-01'))::INT AS dob,
NOW() - (RANDOM() * INTERVAL '365 days') AS created_at
FROM generate_series(1, 10000) AS gs(i);
Next, we analyze scenarios with the following queries
Avoid Using SELECT *
-- Query 1
EXPLAIN ANALYZE
SELECT username FROM users WHERE id = 100
-- Query 2
EXPLAIN ANALYZE
SELECT * FROM users WHERE id = 100
- Query 1: You can see the value of
widthis the number of bytes returned by this query for a single field, which is9 bytes, this size varies depending on the data type of that column - Query 2: When fetching all columns,
width=67, indicating that more data needs to be processed
Use Appropriate Indexes
-- Query 1
SELECT id FROM users WHERE id = 100
-- Query 2
SELECT * FROM users WHERE id = 100
-- Query 3
SELECT * FROM users WHERE dob = '2025-08-29'
- Query 1: Because there is an Index on the id column, this query uses
Index Only Scanwithout scanning data in theHeap - Query 2: To load data for other columns, it uses
Index Scan, which includes scanning the Index andHeap Scan - Query 3: The
dobcolumn does not have anIndex, so it must useSeq Scan
Use Functions on Indexed Columns
-- Query 1
SELECT * FROM users WHERE EXTRACT(YEAR FROM created_at) = 2026;
-- Query 2
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at <= '2026-02-01'
-- Query 3
CREATE INDEX idx_users_created_at_year ON users ((EXTRACT(YEAR FROM created_at)));
SELECT * FROM users WHERE EXTRACT(YEAR FROM created_at) = 2024;
-- Query 4
SELECT * FROM users WHERE username = lower('User_100')
- Query 1: If the
created_atcolumn only has a standardIndex, using it with aFunctionlike this prevents theIndexfrom working - Query 2: Shifting the logic to the
Literal Valueside allows the Index to work normally - Query 3: Adding an
Expression Indexenables the original query to run efficiently - Query 4: Applying a Function to the Value allows the Index to remain active
Direct Calculations on Condition Columns
-- Query 1
SELECT * FROM users WHERE id - 1 = 100
-- Query 2
SELECT * FROM users WHERE id = 100 + 1
- Query 1: Calculating directly on the id column fails to trigger the
Index - Query 2: Moving the calculation to the value side allows the
Indexto operate normally
Use ORDER BY or LIMIT
-- Query 1
SELECT created_at FROM users ORDER BY created_at
-- Query 2
SELECT * FROM users ORDER BY created_at
-- Query 3
SELECT id FROM users LIMIT 10
SELECT created_at FROM users LIMIT 100
SELECT * FROM users LIMIT 1000
-- Query 4
SELECT id FROM users ORDER BY id LIMIT 10;
SELECT * FROM users WHERE id > 100 ORDER BY id LIMIT 10;
SELECT * FROM users ORDER BY created_at DESC LIMIT 10;
- Query 1: Selecting only 1 indexed column uses
Index Only Scan - Query 2: Selecting multiple other columns uses
Index Scan - Query 3: All queries in this section use
Seq Scan, regardless of whether youSELECTanIndexcolumn or set aLIMIT - Query 4: Combining
ORDER BYwithLIMITenables the Index, - Selecting only the Index column uses
Index Only Scan - Fetching additional columns uses
Index Scan - Works whether sorting
ASCorDESC
Implicit Type Mismatch
-- Query 1
SELECT * FROM users WHERE id = '100'
-- Query 2
SELECT * FROM users WHERE created_at = '2026-01-01'
-- Query 3
SELECT * FROM users WHERE created_at >= TIMESTAMPTZ '2026-01-01 00:00:00+00' AND created_at <= TIMESTAMPTZ '2026-02-01 00:00:00+00'
-- Query 4
SELECT * FROM users WHERE balance = 2332.60;
-- Query 5
SELECT * FROM users WHERE balance = 2332.60::double precision;
- Query 1:
Index Scanis still applicable, the string'100'is automatically cast fromTEXTtoBIGINTfor comparison because numeric types have higher precedence than strings (Index Cond: (id = '100'::bigint)) - Query 2: Uses
Index Scan,'2026-01-01'is cast totimestamp(without time zone) - Query 3: Uses
Bitmap Index Scan, cast totimestampz(with time zone) - Query 4: Uses Index Scan normally
- Query 5: Even though the
balancecolumn has anIndex, it fails to operate and switches toSeq Scanbecause it gets cast todouble precisionfor comparison (Filter: ((balance)::double precision = '2332.6'::double precision))
Happy coding!
Comments
Post a Comment