Hook: The optimizer is like a smart shopper: it compares several ways to answer the same query and picks the cheapest-looking basket, not the fanciest shelf.
Question: What is a cost based optimizer in SQL?
Answer: A cost based optimizer is the part of the database that chooses the best execution plan for a query by estimating how much work each plan will do. It looks at table statistics, indexes, and join options, then picks the plan with the lowest estimated cost. This is why the same SQL can run very differently depending on data size, data shape, and index design.
Interview-Ready Answer: I think of the cost based optimizer as the database’s planner. When I send a query, it estimates how many rows each step will touch, compares options like sequential scans, index scans, and join orders, and chooses the plan with the lowest estimated cost. The key detail is that cost is based on statistics, so stale stats or a non-sargable predicate can make the optimizer choose a bad plan.
A cost based optimizer is the database engine’s decision-maker. It does not blindly follow one fixed rule; it estimates the work for several possible plans and chooses the cheapest one. The word cost is important: this is usually an internal score, not wall-clock time in milliseconds.
Two core ideas drive the choice:
For example, if a table has 10 million orders and only 500 rows have status = 'pending', the optimizer may prefer an index. If 9 million rows match, a sequential scan can be cheaper because reading the table once is faster than bouncing through the index and then fetching almost every row anyway.
ANALYZE; the default default_statistics_target is 100.| Aspect | Rule-based | Cost-based |
|---|---|---|
| Decision style | Fixed rules | Estimate costs |
| Index use | Pattern driven | May skip index |
| Input needed | Rules only | Stats + indexes |
| Weak spot | Rigid plans | Bad stats |
The big win of cost based planning is flexibility. The same query can choose a different plan as the table grows, the data distribution changes, or a new index appears.
An index gives the optimizer another access path. But an index is only useful when the predicate is sargable (search-argument-able), meaning the database can use the column directly to search efficiently. A filter like created_at >= '2025-02-01' is friendly to an index. A filter like DATE(created_at) = '2025-02-01' often hides the column inside a function, which can block a normal index unless you create an expression index.
ANALYZE, the optimizer may guess badly.(status, created_at) helps when you filter by status first.Memory hook: Think “GPS with live traffic.” The optimizer does not just know the roads (rules); it checks the traffic report (statistics) and chooses the cheapest route it can see.
Real-World Story: In a checkout service, the team had an orders table with tens of millions of rows. A dashboard query filtered by status and recent created_at values, but a developer wrapped the timestamp in DATE() to make the SQL look simpler. The cost based optimizer could no longer use the normal index well, so it switched to a sequential scan.
What happened in production? p95 latency jumped from tens of milliseconds to multiple seconds, the database CPU spiked, and the slow query log started showing Seq Scan on orders with millions of rows removed by filter. Users saw the checkout page spinning, order confirmations arriving late, and support tickets saying recent orders were missing from the admin view.
The fix was simple but important: rewrite the predicate to a half-open range, keep the column bare, and let the optimizer do its job. If the business truly needed DATE(created_at), the team could also add an expression index. The lesson was that the optimizer is smart, but it can only reason about what the SQL exposes.
-- PostgreSQL demo: show how the optimizer changes plans based on data shape and index friendliness.
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
customer_id INT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
total_amount NUMERIC(10,2) NOT NULL
);
-- Build a realistic data set: many rows, but only a small fraction are pending.
INSERT INTO orders (customer_id, status, created_at, total_amount)
SELECT
(g % 1000) + 1,
CASE
WHEN g % 20 = 0 THEN 'pending' -- rare value: good candidate for index use
WHEN g % 5 = 0 THEN 'shipped'
ELSE 'paid'
END,
TIMESTAMP '2025-01-01' + (g || ' minutes')::interval,
((g % 250) + 1) * 1.00
FROM generate_series(1, 50000) AS g;
-- Composite index: the optimizer can use it well when the leading column is filtered first.
CREATE INDEX idx_orders_status_created_at ON orders (status, created_at);
ANALYZE orders;
-- Good shape: equality on the leading column + range on the second column.
-- This is sargable, so the optimizer can consider an index path.
EXPLAIN
SELECT order_id, customer_id, created_at
FROM orders
WHERE status = 'pending'
AND created_at >= TIMESTAMP '2025-02-01'
ORDER BY created_at
LIMIT 20;
-- Failure path: wrapping the indexed column in DATE() can block a normal index use.
-- The optimizer may have to scan more rows because the predicate is less searchable.
EXPLAIN
SELECT order_id, customer_id, created_at
FROM orders
WHERE DATE(created_at) = DATE '2025-02-01'
AND status = 'pending';
-- Better rewrite: keep the column bare and use a half-open range.
-- This form is easier for the optimizer to cost accurately and for the index to support.
EXPLAIN
SELECT order_id, customer_id, created_at
FROM orders
WHERE status = 'pending'
AND created_at >= TIMESTAMP '2025-02-01'
AND created_at < TIMESTAMP '2025-02-02';
-- If the business really wants DATE(created_at), an expression index can make that predicate searchable.
CREATE INDEX idx_orders_created_date ON orders ((DATE(created_at)));
ANALYZE orders;
EXPLAIN
SELECT order_id, customer_id, created_at
FROM orders
WHERE DATE(created_at) = DATE '2025-02-01'
AND status = 'pending';
-- Broad query: even with an index, the optimizer may prefer a sequential scan when most rows match.
EXPLAIN
SELECT count(*)
FROM orders
WHERE status IN ('paid', 'shipped', 'pending');Follow-up & Tricky Questions:
EXPLAIN and EXPLAIN ANALYZE? EXPLAIN shows the estimated plan; EXPLAIN ANALYZE actually runs the query and reports real timing and row counts. That comparison is one of the fastest ways to spot bad estimates.(status, created_at) is great for status = ... plus a date range, but weak for filtering only on created_at.Common Mistakes:
ANALYZE or rely on autovacuum so the optimizer sees current data shape.Memory Hook: GPS with live traffic. The optimizer is not a rule book; it is a route planner that checks traffic stats before choosing the cheapest road.
Cheat Sheet:
EXPLAIN to inspect the chosen plan.Practice Tasks:
EXPLAIN output before and after the expression index.pending ratio in the data and see when the planner starts preferring a sequential scan.