Performance Optimization — Learn EXPLAIN, Index Scan, Sargability, and Top-N from the Basics
BasicEXPLAIN ANALYZEIndex ScanSargableLIMIT / Top-NEXISTSPostgreSQL5 questions
QUESTION 1
Execution Plan Basics — Distinguish Seq Scan from Index Scan with EXPLAIN ANALYZE
EXPLAINANALYZEExecution planSeq vs Index
Background

The first step in making SQL faster is learning to read the execution plan. EXPLAIN shows how the database plans to execute a query, while EXPLAIN ANALYZE actually executes it and returns measured values as well. The representative nodes are Seq Scan (scanning every row) and Index Scan (accessing rows through an index); for a query with a low selectivity, Index Scan is dramatically faster.

-- EXPLAIN: plan only / EXPLAIN ANALYZE: plan + measurements (the query executes)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE order_id = 50000;

-- How to read the plan node:
--   Index Scan using orders_pkey on orders
--     (cost=0.29..8.31 rows=1 width=64)
--     (actual time=0.018..0.020 rows=1 loops=1)
Two primary nodes: Seq Scan reads every row in order from the beginning of the table, so its cost is proportional to table size. Index Scan follows the B-tree and accesses the target row directly, so its cost is logarithmic. For a query that retrieves only one row, such as WHERE primary_key = value, the planner chooses Index Scan.
Problem

Retrieve the one row with order_id = 50000 from the orders table (100,000 rows; order_id is the primary key). Write SQL that outputs the execution plan with EXPLAIN ANALYZE, and read the difference between Seq Scan and Index Scan.

Target table (conceptual view, shown as 6 rows)
- orders (real table: 100,000 rows / 6 example rows)
order_id (PK)customer_idamountstatus
11011200completed
2102800completed
31032000cancelled
4 *1043000completed
51051500completed
6106500completed

※ * marks the target row (in the real table it corresponds to order_id = 50000). The visualization treats it as “the fourth row” for clarity.

Expected Output

Illustrative EXPLAIN ANALYZE output (comparison with and without an index):

ScenarioNodecostactual timeRows read
With primary key (PK)Index Scan0.29..8.310.02 ms1 row
No indexSeq Scan0..183412.4 ms100,000 rows

Even when the query returns the same one row, different plans can make the measured cost differ by hundreds of times.

Model Answer
EXPLAIN (ANALYZE, BUFFERS)  -- Get the plan, measured values, and buffer usage
SELECT *
FROM   orders
WHERE  order_id = 50000;    -- Primary-key equality → Index Scan is selected

/*
  Read path:
  1. The planner confirms that the orders_pkey index exists
  2. It chooses Index Scan because equality has extremely low selectivity (1/100000)
  3. It follows the B-tree to the target row in log2(100000) ≈ 17 steps
  4. It fetches one block and finishes (hundreds of times faster than Seq Scan)

  Reading the example output:
    Index Scan using orders_pkey on orders
      (cost=0.29..8.31 rows=1 width=64)          ← estimated values
      (actual time=0.018..0.020 rows=1 loops=1)  ← measured values
    Planning Time: 0.10 ms
    Execution Time: 0.04 ms
*/
Explanation (table transitions & key points)
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_id = 50000;
LEGEND
Rows read / loaded
1. Target table — orders (100,000 rows, 6 shown)
FROM ordersThis is the state before reading the entire orders table. order_id is the primary key, so a B-tree index is created automatically. There is only one target row: order_id = 4 (50000 in the real table).
1 / 4
order_idcustomer_idamountstatus
11011200completed
2102800completed
31032000cancelled
41043000completed
51051500completed
6106500completed
6 rows (real table: 100,000 rows)
LEARNING POINTS
EXPLAIN is an estimate; ANALYZE is a measurement: EXPLAIN only displays costs estimated from statistics. EXPLAIN ANALYZE actually runs the query and returns actual time and actual rows. A large gap between estimated and actual rows is a sign that the statistics need updating with ANALYZE table_name.
Selectivity is the decision axis for Seq Scan versus Index Scan: Index Scan is advantageous when the result is a few percent or less of the table, while Seq Scan can be faster when the query returns more than several tens of percent. The planner chooses automatically from statistics, so understand that an index is not always faster.
BUFFERS shows I/O volume: EXPLAIN (ANALYZE, BUFFERS) displays shared hit / read, revealing cache hits and reads from the actual disk. Comparing I/O as well as execution time prevents a cache-only “speedup” from looking better than it is.
ANTI-PATTERNS
Casually run EXPLAIN ANALYZE on a write: EXPLAIN ANALYZE UPDATE ... actually performs the update. Accidentally running it in production changes data. To inspect only the plan for a write, use EXPLAIN without ANALYZE, or use BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; for a safe check.
Assuming “Seq Scan = bad”: Seq Scan can be faster for a small table or a query that retrieves most of its rows. Before questioning the planner's choice, check selectivity and table size. Adding an index does not automatically make a query faster.
Field Notes: Measure before fixing performance
The most common failure in performance discussions is adding an index by intuition or rewriting a query by intuition. EXPLAIN ANALYZE is the strongest tool for preventing that: simply compare cost / actual time before and after a change to judge its effect objectively. The “Sargable WHERE,” “avoiding SELECT *,” and “EXISTS / Top-N” topics in Q2–Q5 should likewise be approached by checking with EXPLAIN how the plan changes. Remember: an optimization that cannot be measured does not exist.
QUESTION 2
Sargable WHERE Clauses — Do Not Wrap the Column in a Function; Preserve the Index
SargableINDEXLIKEWHERE optimization
Background

A condition that can use an index is called Sargable (Search ARGument-able). Conversely, wrapping the column on the left side in a function or expression disables the index and falls back to Seq Scan. For LIKE, only a prefix match such as ('abc%') can use an index; a wildcard at both ends ('%abc%') cannot.

-- ✗ Non-Sargable: apply a function to the column → index disabled
WHERE EXTRACT(YEAR FROM created_at) = 2024

-- ✓ Sargable: rewrite as a range condition → Index Range Scan
WHERE created_at >= '2024-01-01'
  AND created_at <  '2025-01-01'
Three Sargability rules: (1) leave the column bare on the left, (2) do not wrap it in a function, expression, or type conversion, and (3) use only prefix matches with LIKE. Even conditions that return the same result can use an index differently depending on their spelling. Remember that “same result” and “same plan” are separate things.
Problem

The created_at column in the orders table has a B-tree index. Write a Sargable SQL query that uses the index to retrieve orders created during 2024.

Input table
- orders (7 rows; index on created_at)
order_idcreated_atamount
12023-11-201200
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500
62025-01-02500
72025-03-10700

※ The 2024 rows are ids 2–5, four rows. The standard boundary is >= '2024-01-01' AND < '2025-01-01', expressed as “before New Year's Day of the following year.”

Expected Output

Expected output:

order_idcreated_atamount
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500

The same four rows can come from either an Index Range Scan or a Seq Scan depending on how the condition is written.

Model Answer
SELECT order_id, created_at, amount
FROM   orders
WHERE  created_at >= '2024-01-01'    -- Keep the column bare and compare it with a constant (Sargable)
  AND  created_at <  '2025-01-01'    -- Express the boundary as “before New Year's Day” (half-open range)
ORDER BY order_id;

/*
  Execution order:
  1. Binary-search the index for the start of the range
  2. Follow the leaves to scan continuously until the end boundary
  3. Read the selected columns from the table (or use Index Only Scan)
  4. Sort by order_id
  */
Explanation (table transitions & key points)
SELECT order_id, created_at, amount FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' ORDER BY order_id;
LEGEND
Rows read / loaded
1. FROM orders (B-tree index on created_at)
FROM ordersFour of the 7 rows are in 2024: ids 2, 3, 4, and 5. The created_at index can be used or not depending on how the predicate is written.
1 / 4
order_idcreated_atamount
12023-11-201200
22024-01-15800
32024-05-032000
42024-09-303000
52024-12-311500
62025-01-02500
72025-03-10700
7 rows (the index can reduce the work)
LEARNING POINTS
The moment you wrap a column in a function, the index is disabled: EXTRACT(YEAR FROM created_at), UPPER(name), LOWER(email), and CAST(col AS ...) all transform the column on the left, so the B-tree cannot look up that value. Leave the column bare and make the right side a constant—that is the Sargability rule.
Use a half-open range for dates: BETWEEN '2024-01-01' AND '2024-12-31' has the trap that it can miss the time portion of December 31 (23:59:59.999...). >= '2024-01-01' AND < '2025-01-01' handles time, time zones, and leap seconds without depending on a last representable instant, and it naturally supports Index Range Scan.
Only prefix LIKE can use an index: name LIKE 'Tan%' can use a B-tree range scan, but name LIKE '%Tan%' and '%Tan' cannot. When middle or suffix matching is required, the proper solution is a separate full-text search index such as GIN/pg_trgm.
ANTI-PATTERNS
Destroying index use with an implicit type conversion: a comparison with mismatched types, such as WHERE varchar_col = 12345, can make the database insert a CAST on one side and disable the index. Pass the value with the application-side type, or write the literal as '12345', and Index Scan can return.
Make one side of an OR non-Sargable: WHERE a = 1 OR UPPER(b) = 'X' can become a Seq Scan for the whole predicate because one side is non-Sargable. Make both sides Sargable, or split the query into two with UNION ALL.
Field Notes: An expression index is a last resort
When searches using a function are genuinely frequent, an expression index (an index on the expression) is an option. For example, CREATE INDEX ON users (LOWER(email)); lets WHERE LOWER(email) = ... use an index. Still, first ask whether the query can be rewritten to be Sargable. Expression indexes increase maintenance cost and write load, so the practical order is rewrite the query > expression index > full-text search index.
QUESTION 3
The SELECT * Trap and LIMIT — Minimize Read Volume with Explicit Columns and Row Limits
Explicit columnsLIMITTop-NIndex Only Scan
Background

Query cost is proportional to “columns read × rows read.” SELECT * reads every column, increasing disk I/O and network transfer, and it prevents a covering index or Index Only Scan from helping. LIMIT returns only the required number of rows; with an index that already supplies the requested order, it can finish early. Combined with ORDER BY, even when the input must be fully examined, it enables the efficient specialized algorithm called Top-N sort.

-- ✗ Every column and every row: unnecessary I/O and sorting
SELECT * FROM orders ORDER BY amount DESC;

-- ✓ Required columns only + LIMIT: less I/O + Top-N sort
SELECT order_id, amount FROM orders
ORDER BY amount DESC LIMIT 3;
LIMIT changes the algorithm: for a large table, specifying ORDER BY ... LIMIT K lets the planner choose a Top-N heap sort (O(N log K)) instead of a full sort. Only K rows need to be kept in memory, so the benefit grows as N grows. Simply declaring that only the needed rows should be returned can change the plan itself.
Problem

Retrieve the top three orders by amount from orders, returning order_id and amount. Minimize the columns read and limit the number of rows with LIMIT.

Input table
- orders (6 rows × 6 columns)
order_idcustomer_idamountstatuscreated_atnote
11011200completed2024-01-15...
2102800completed2024-02-20...
31035000completed2024-03-10...
41043000cancelled2024-04-05...
51051500completed2024-05-12...
61064200completed2024-06-30...

※ The top three by descending amount are id=3 (5000), 6 (4200), and 4 (3000). In production, note is assumed to be long TEXT.

Expected Output

Expected output (amount descending, top 3):

order_idamount
35000
64200
43000

Reducing the result to 2 columns × 3 rows makes the transferred result about (2/6 columns) × (3/6 rows) = 1/6 of the original. ORDER BY + LIMIT also enables Top-N sort.

Model Answer
SELECT order_id, amount  -- Explicit columns: only the required 2; preserve I/O, network, and Index Only Scan opportunities
FROM   orders
ORDER BY amount DESC     -- Sort by amount descending
LIMIT    3;              -- Top-N: keep only the top 3; use a heap rather than retaining a full sort

/*
  Execution order (logical SQL evaluation order):
  1. FROM orders              → scan 6 rows
  2. ORDER BY amount DESC     → Top-N sort (keep only the leading rows)
  3. LIMIT 3                  → finalize the retained top 3 (an ordered index could allow early termination)
  4. SELECT order_id, amount  → return only the required 2 columns

  Plan comparison (conceptual):
  ✗ SELECT *  ORDER BY amount DESC;
       → read all 6 columns → fully sort 6 rows → return all 6 rows
  ✓ SELECT order_id, amount ORDER BY amount DESC LIMIT 3;
       → read 2 columns → Top-N sort with heap size 3 → return 3 rows
*/
Explanation (table transitions & key points)
SELECT order_id, amount FROM orders ORDER BY amount DESC LIMIT 3;
LEGEND
Rows read / loaded
1. FROM orders — source table (6 rows × 6 columns)
FROM ordersOnly 2 of the 6 columns are needed: order_id and amount. note is assumed to be long TEXT, so SELECT * would transfer a great deal of unnecessary data.
1 / 5
order_idcustomer_idamountstatuscreated_atnote
11011200completed2024-01-15...
2102800completed2024-02-20...
31035000completed2024-03-10...
41043000cancelled2024-04-05...
51051500completed2024-05-12...
61064200completed2024-06-30...
6 rows × 6 columns (reading every column is expensive)
LEARNING POINTS
SELECT * is easy but expensive: reading every column inflates I/O, memory, and network transfer, and prevents Index Only Scan (the fastest plan that does not touch the table itself). Merely naming the required columns can change the plan. “Just use *” is one of the quietest ways to reduce production performance.
ORDER BY + LIMIT becomes Top-N sort: with LIMIT, the planner can maintain a heap of size K while scanning rather than fully sorting all rows. Even with N=1 million and K=10, memory usage is only 10 rows. For ranking queries, adding or omitting LIMIT changes the algorithm completely.
A covering index brings I/O close to zero: an index covering the search key and selected columns, such as (amount DESC, order_id) INCLUDE (...), allows Index Only Scan without touching the table itself. SELECT * loses this benefit completely, so explicit columns pair naturally with this optimization.
ANTI-PATTERNS
Fetch every row without LIMIT in production: incidents where an admin list runs SELECT * FROM huge_table are common. It works while the table is small, then stops with a timeout or out-of-memory error as data grows. Add “pagination = LIMIT + OFFSET (or key-set pagination)” from the beginning.
Paginate with a huge OFFSET: LIMIT 20 OFFSET 1000000 internally reads and discards 1 million rows before returning 20. For deep pagination, switch to a key-range condition (WHERE id > the last id from the previous page LIMIT 20). Use OFFSET only for shallow pages.
Field Notes: Why “SELECT *” stops at code review
Experienced reviewers flag SELECT * not only for performance but also for resilience to future changes. If a large JSON or BLOB column is added later, SELECT * suddenly slows unrelated code's responses. It also makes column additions and removals ripple through ORM cache keys, API response contracts, and test snapshots. Explicit columns make dependencies explicit: this is maintainability tuning as well as performance tuning.
QUESTION 4
EXISTS versus IN — Use EXISTS for Early-Exit Existence Checks
EXISTSINNOT EXISTSCorrelated subquery
Background

IN and EXISTS are predicates for checking whether something exists in another table. EXISTS stops evaluating as soon as it finds one match, while IN can be understood as collecting the subquery result and then comparing against it (the optimizer may transform either form in practice). Also, if a subquery contains NULL, NOT IN has the famous trap of making every row evaluate to FALSE/UNKNOWN; NOT EXISTS is safer.

-- ✓ EXISTS: stop after finding one match; safe around NULL
SELECT c.*
FROM   customers c
WHERE  EXISTS (
  SELECT 1 FROM orders o
  WHERE  o.customer_id = c.customer_id
);
Three strengths of EXISTS: (1) it stops after finding one match, so the inner SELECT list can contain anything and SELECT 1 is conventional; (2) its Boolean result is not affected by NULL; and (3) correlated conditions can use an index effectively. If you only need to know whether something exists, EXISTS is the first choice.
Problem

From customers and orders, retrieve customers who have placed at least one order. Use a correlated subquery with EXISTS in a NULL-safe form. Return customer_id, name, ordered by customer_id ascending.

Input tables
- customers (4 rows)
customer_idname
101Alice
102Bob
103Carol
104Dan
- orders (5 rows, including NULL)
order_idcustomer_idamount
11011200
2101800
31032000
4NULL500
51031500

Customers with orders are 101 (Alice) and 103 (Carol). 102 and 104 have no orders. Order id=4 has a NULL customer_id.

Expected Output

Expected output (customer_id ascending):

customer_idname
101Alice
103Carol

Trying to find customers without orders with NOT IN would return zero rows because of the NULL (the trap is explained below).

Model Answer
SELECT   c.customer_id, c.name
FROM     customers c
WHERE    EXISTS (                       -- Existence check: TRUE if even one matching orders row exists
  SELECT 1                              -- The contents do not matter; conventionally use “1” (minimal)
  FROM   orders o
  WHERE  o.customer_id = c.customer_id  -- Correlation: match the outer c.customer_id (NULL does not match with =)
)
ORDER BY c.customer_id;                 -- Sort by customer_id ascending

/*
  Execution order (logical SQL evaluation order):
  1. FROM customers c      → scan 4 outer rows
  2. Evaluate EXISTS for each c → stop when a match is found
  3. ORDER BY customer_id   → sort ascending

  Why NOT IN is unsafe:
    WHERE customer_id NOT IN (SELECT customer_id FROM orders)
    → the subquery result is {101,101,103,NULL,103}
    → when NOT IN contains NULL, every row becomes UNKNOWN (not TRUE), so the result is 0 rows.
    → with NOT EXISTS, NULL does not match the correlated condition and is safe.
*/
Explanation (table transitions & key points)
SELECT c.customer_id, c.name FROM customers c WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id ) ORDER BY c.customer_id;
LEGEND
Rows read / loaded
1. Read 2 tables — customers and orders
FROM customers c, orders oThere are 4 customers and 5 orders. Note the order with a NULL customer_id (id=4); this creates the difference in behavior from NOT IN.
1 / 3
srckey1key2val
customers101Alice
customers102Bob
customers103Carol
customers104Dan
orderso.id=1cust=1011200
orderso.id=2cust=101800
orderso.id=3cust=1032000
orderso.id=4cust=NULL500
orderso.id=5cust=1031500
customers=4, orders=5 (includes NULL)
LEARNING POINTS
EXISTS means “finish when one match is found”: the inner subquery stops as soon as one matching row is found. The result is the same no matter what appears in its SELECT list, so SELECT 1 is conventional. SELECT * would also produce the same result, but reviewers often flag it. EXISTS is the most direct and fast form when you only need to know whether something exists.
NOT IN with NULL becomes UNKNOWN for every row: under SQL's three-valued logic, x NOT IN (1, 2, NULL) is UNKNOWN. WHERE requires TRUE, so it excludes every row. If even one NULL enters orders, using NOT IN to find customers without orders can make the result suddenly become zero rows. With NOT EXISTS, NULL does not match the correlated condition and is safe.
EXISTS pairs well with a correlated index: the inner condition o.customer_id = c.customer_id runs for each outer-loop value, so an index on orders(customer_id) can make each loop an immediate Index Scan decision. JOIN + DISTINCT can express the same idea, but it often costs more because duplicates must be removed after the join.
ANTI-PATTERNS
Use NOT IN against a nullable column: a typical accident is writing WHERE customer_id NOT IN (SELECT customer_id FROM orders) to find customers without orders. One NULL in orders makes the result always zero rows. For a set difference, use NOT EXISTS or LEFT JOIN ... WHERE o.id IS NULL.
Use SELECT * + ORDER BY inside EXISTS: WHERE EXISTS (SELECT * FROM orders ORDER BY ...) adds unnecessary work. EXISTS stops after one row, so ORDER BY has no meaning, and the SELECT list does not affect the result. Standardize on SELECT 1 and keep needless work out of the inner query.
Field Notes: Choosing among IN, EXISTS, and JOIN + DISTINCT
Even for the same existence check, syntax affects the plan and readability. 1. If the value list is fixed and short, IN ('A','B','C') is clearest. 2. If you are testing membership in a set returned by a subquery, use EXISTS (NULL-safe with early exit). 3. If you also need columns from the child table, use JOIN (but duplicates may require DISTINCT or GROUP BY). In practice, mechanically choosing “EXISTS for existence only, JOIN when you need columns” stabilizes both performance and readability. For a NOT-style set difference, make NOT EXISTS the first choice; NOT IN always hides a NULL land mine.
QUESTION 5
Aggregation and LIMIT Combined — Top-N, DISTINCT versus GROUP BY, and COUNT
ORDER BY+LIMITGROUP BYDISTINCTTop-N aggregation
Background

This final quiz in the performance fundamentals set combines aggregation with Top-N, a common production pattern. Build the grain with GROUP BY, then take only the top results with ORDER BY ... LIMIT—this two-stage approach is standard for reporting queries, and its exact form changes I/O, memory, and execution time. We will also organize the differences among DISTINCT and GROUP BY, and COUNT(*) and COUNT(column).

-- Standard pattern: aggregate with GROUP BY, then take the top rows with ORDER BY + LIMIT
SELECT   category, SUM(amount) AS total
FROM     orders
GROUP BY category
ORDER BY total DESC
LIMIT    3;
Three things that look the same but differ: (1) COUNT(*) = all rows versus COUNT(column) = excluding NULL versus COUNT(DISTINCT column) = unique values. (2) DISTINCT and GROUP BY often have equivalent internal implementations, but GROUP BY is natural when adding aggregate functions. (3) ORDER BY ... LIMIT K becomes a Top-N heap sort instead of a full sort.
Problem

From orders, retrieve the top 3 categories by total sales and the number of orders in each category. Return category, order_count, total_amount, sorted by total_amount descending and then category ascending for ties.

Input table
- orders (8 rows)
order_idcategoryamount
1Books1200
2Books800
3Toys5000
4Toys3000
5Food500
6Games2500
7Games1500
8Music700

※ Return the top 3 of the 5 categories by total. Even if the real table has 1 million rows, Top-N sort speed after aggregation depends on the number of categories, which is small.

Expected Output

Expected output (total_amount descending, top 3):

categoryorder_counttotal_amount
Toys28000
Games24000
Books22000

Food (500) and Music (700) are excluded. After aggregation, a Top-3 heap keeps only the leading results.

Model Answer
SELECT   category,
         COUNT(*)    AS order_count,  -- All rows in the group (including NULLs)
         SUM(amount) AS total_amount  -- Total amount in the group
FROM     orders
GROUP BY category                     -- Aggregate at category grain
ORDER BY total_amount DESC, category  -- Total descending; category ascending breaks ties
LIMIT    3;                           -- Top-N: keep only the top 3 (a full sort is unnecessary)

/*
  Execution order (logical SQL evaluation order):
  1. FROM orders          → read 8 rows
  2. GROUP BY category    → aggregate by category
  3. ORDER BY total DESC  → Top-N sort (keep only the leading rows)
  4. LIMIT 3              → finalize the retained top 3

  COUNT variants (reference):
    COUNT(*)                       … all rows (including NULL; often the fastest)
    COUNT(amount)                  … rows where amount is not NULL
    COUNT(DISTINCT customer_id)    … unique customers (requires hash deduplication)
*/
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY category ORDER BY total_amount DESC, category LIMIT 3;
LEGEND
Rows read / loaded
1. FROM orders — read 8 rows
FROM ordersRead 8 rows across 5 categories. Although the final result needs only the top 3 categories, every category must first be aggregated.
1 / 5
order_idcategoryamount
1Books1200
2Books800
3Toys5000
4Toys3000
5Food500
6Games2500
7Games1500
8Music700
8 rows (5 categories)
LEARNING POINTS
GROUP BY → ORDER BY + LIMIT is the standard aggregation-ranking pattern: reports such as “top 10 categories by sales” and “top 50 days by page views” all fit this shape. It is a two-stage process that dramatically reduces rows through aggregation and then takes only the leading rows with Top-N heap sort. If the number of groups after aggregation is small, sorting is effectively free.
DISTINCT and GROUP BY are often internally equivalent: SELECT DISTINCT category FROM orders and SELECT category FROM orders GROUP BY category produce the same execution plan in many databases. The difference is whether you want to add aggregate functions. Use GROUP BY with aggregation and DISTINCT for unique values only, matching the intent.
COUNT(*) is fastest; COUNT(column) excludes NULL: COUNT(*) is a simple row count and often the lightest form, while COUNT(amount) excludes rows where amount is NULL. Optimizers treat COUNT(1) as equivalent to COUNT(*), so there is no performance difference. Choose mechanically based on whether the column being counted can be NULL.
ANTI-PATTERNS
Fetch every row and truncate in the application: returning all rows with SELECT ... ORDER BY total DESC and then applying list[:3] in application code is the worst pattern. The database still fully sorts every row, and the network still transfers every row. Remember that the presence or absence of LIMIT can change the entire plan.
Sprinkle DISTINCT on as a “just in case” deduplication: removing inflated JOIN rows with SELECT DISTINCT is a symptomatic fix that avoids the cause (an incomplete JOIN condition). Prefer GROUP BY or EXISTS without creating the inflation. DISTINCT performs hash deduplication internally, so using it blindly can create hidden performance degradation.
Field Notes: The power of “aggregate, then take the top”
The Q5 pattern—aggregate with GROUP BY, then take the top rows with ORDER BY + LIMIT—is the standard form used most often in dashboards and reporting features. The key design idea is to fold data down as far as possible in the database (aggregation) and return only the required number of rows (Top-N) to the application. If the application receives all data and aggregates and sorts it in a programming language, the network and memory fail as the data grows. Also, instead of abusing DISTINCT just to remove duplicates, use an intentional GROUP BY to make the query's purpose clear. Fully using the database's powerful aggregation engine is a core part of performance optimization in practice.