{"id":"l-sql-4-2","courseId":"sql-for-analysts","lang":"en","kind":"lesson","summary":"Read EXPLAIN QUERY PLAN output to tell a full scan from an index search, write filters the database can serve from an index, and judge when a new index is worth asking for.","html":"<p>Mark has a dashboard that shows orders for a chosen day, and on the production database it’s become slow. “Same query as always. Why is it suddenly slow?” On Cartwheel’s two thousand orders, every query in this course returns instantly. Production has years of orders, and there the difference between a query that reads every row and one that jumps to the right rows is seconds versus minutes. You can’t feel that difference on a small database, but you can <strong>see</strong> it in the query plan.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">What an index is</h2>\n<p>An index is a separate structure that keeps the values of one or more columns in sorted order, each with a pointer to its row. Finding ‘2025-11-28’ in a sorted list is like finding a word in a dictionary: you jump to roughly the right page instead of reading from the start. Without an index, the database has to read the whole table and test every row, which is a <strong>scan</strong>.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 720 230\" role=\"img\" aria-labelledby=\"l-sql-4-2-t1\"><defs><marker id=\"arrow-l-sql-4-2-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-sql-4-2-t1\">Left: a full scan checks all rows of the orders table one by one. Right: the idx_orders_date index holds dates in sorted order; a search jumps to 2025-11-28, reads the matching entries and follows their pointers to the rows.</title>\n  <text class=\"d-label-strong\" x=\"160\" y=\"28\" text-anchor=\"middle\">SCAN orders</text>\n  <rect class=\"d-box\" x=\"60\" y=\"40\" width=\"200\" height=\"26\" rx=\"4\"/>\n  <rect class=\"d-box\" x=\"60\" y=\"70\" width=\"200\" height=\"26\" rx=\"4\"/>\n  <rect class=\"d-box\" x=\"60\" y=\"100\" width=\"200\" height=\"26\" rx=\"4\"/>\n  <rect class=\"d-box\" x=\"60\" y=\"130\" width=\"200\" height=\"26\" rx=\"4\"/>\n  <rect class=\"d-box\" x=\"60\" y=\"160\" width=\"200\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-label-muted\" x=\"160\" y=\"215\" text-anchor=\"middle\">test every row</text>\n  <path class=\"d-arrow\" d=\"M40 45 L40 185\" marker-end=\"url(#arrow-l-sql-4-2-1)\"/>\n  <text class=\"d-label-strong\" x=\"450\" y=\"28\" text-anchor=\"middle\">SEARCH idx_orders_date</text>\n  <rect class=\"d-box\" x=\"370\" y=\"40\" width=\"160\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-code\" x=\"450\" y=\"58\" text-anchor=\"middle\">2024-01-02</text>\n  <rect class=\"d-box\" x=\"370\" y=\"70\" width=\"160\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-code\" x=\"450\" y=\"88\" text-anchor=\"middle\">…</text>\n  <rect class=\"d-box-success\" x=\"370\" y=\"100\" width=\"160\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-code\" x=\"450\" y=\"118\" text-anchor=\"middle\">2025-11-28 08:…</text>\n  <rect class=\"d-box-success\" x=\"370\" y=\"130\" width=\"160\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-code\" x=\"450\" y=\"148\" text-anchor=\"middle\">2025-11-28 23:…</text>\n  <rect class=\"d-box\" x=\"370\" y=\"160\" width=\"160\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-code\" x=\"450\" y=\"178\" text-anchor=\"middle\">2025-12-30</text>\n  <path class=\"d-arrow\" d=\"M330 50 L365 108\" marker-end=\"url(#arrow-l-sql-4-2-1)\"/>\n  <rect class=\"d-box-accent\" x=\"580\" y=\"100\" width=\"120\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-label\" x=\"640\" y=\"118\" text-anchor=\"middle\">row 2290</text>\n  <rect class=\"d-box-accent\" x=\"580\" y=\"130\" width=\"120\" height=\"26\" rx=\"4\"/>\n  <text class=\"d-label\" x=\"640\" y=\"148\" text-anchor=\"middle\">row 1769</text>\n  <path class=\"d-arrow\" d=\"M530 113 L576 113\" marker-end=\"url(#arrow-l-sql-4-2-1)\"/>\n  <path class=\"d-arrow\" d=\"M530 143 L576 143\" marker-end=\"url(#arrow-l-sql-4-2-1)\"/>\n  <text class=\"d-label-muted\" x=\"530\" y=\"215\" text-anchor=\"middle\">jump, read the range, follow pointers</text>\n</svg><figcaption>A scan reads every row; an index search jumps to the matching range</figcaption></figure>\n<p>Cartwheel’s database has a few indexes already. You can list them from the schema table:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, tbl_name, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">sql</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sqlite_schema</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> type</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'index'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p><code dir=\"ltr\">orders</code> is indexed on <code dir=\"ltr\">customer_id</code> and <code dir=\"ltr\">order_date</code>, <code dir=\"ltr\">order_items</code> on <code dir=\"ltr\">order_id</code> and <code dir=\"ltr\">product_id</code>. The <code dir=\"ltr\">sqlite_autoindex_customers_1</code> entry exists because <code dir=\"ltr\">email</code> is declared UNIQUE; SQLite enforces uniqueness with an index.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Reading a query plan</h2>\n<p>Prefix any SELECT with <code dir=\"ltr\">EXPLAIN QUERY PLAN</code> and SQLite describes how it will run it instead of running it. The interesting part is the <code dir=\"ltr\">detail</code> column:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"1\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">EXPLAIN QUERY PLAN</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">status</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_date </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">>=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-11-28'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_date </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">&#x3C;</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  '2025-11-29'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p><code dir=\"ltr\">SEARCH orders USING INDEX idx_orders_date (order_date&gt;? AND order_date&lt;?)</code>. <strong>SEARCH</strong> means the index narrowed the work to a range. Now Mark’s dashboard query, which someone “simplified” last month:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"2\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">EXPLAIN QUERY PLAN</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_id, </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">status</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> date</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(order_date) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-11-28'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"2\" hidden></div></div>\n<p><code dir=\"ltr\">SCAN orders</code>. Same result, but now every row is read and <code dir=\"ltr\">date()</code> is computed for each one. The index stores raw <code dir=\"ltr\">order_date</code> values; it knows nothing about <code dir=\"ltr\">date(order_date)</code>, so it can’t help. That’s the “suddenly slow”.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Wrapping the indexed column</span></p>\n<p>A filter can use an index only when the indexed column stands alone on one side of the comparison. <code dir=\"ltr\">date(order_date) = ...</code>, <code dir=\"ltr\">strftime('%Y', order_date) = '2025'</code>, <code dir=\"ltr\">customer_id + 0 = 42</code> and <code dir=\"ltr\">LOWER(email) = ...</code> all hide the column inside an expression. Rewrite them as ranges on the bare column: <code dir=\"ltr\">order_date &gt;= '2025-01-01' AND order_date &lt; '2026-01-01'</code>. Conditions that can use an index are often called <strong>sargable</strong>. The half-open ranges you learned in the dates lesson are sargable by design.</p>\n</aside>\n<p>Two more plan phrases are worth recognising. <code dir=\"ltr\">USING COVERING INDEX</code> means the index alone held every column the query needed, so the table wasn’t touched at all, the fastest kind of lookup. And <code dir=\"ltr\">USE TEMP B-TREE FOR GROUP BY</code> or <code dir=\"ltr\">FOR ORDER BY</code> means SQLite had to sort rows itself, which is fine on small results and worth a look on large ones.</p>\n<p>Plans for joins show one line per table, in the order SQLite visits them:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"3\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">EXPLAIN QUERY PLAN</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">SUM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">quantity</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> units</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> orders </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> o</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">JOIN</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> order_items </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> oi </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ON</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> oi</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">customer_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 42</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> o</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">order_id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"3\" hidden></div></div>\n<p>Both lines say SEARCH: find customer 42’s orders through <code dir=\"ltr\">idx_orders_customer</code>, then each order’s items through <code dir=\"ltr\">idx_items_order</code>. An index on every foreign key you join on is the single most valuable indexing habit.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Plans are predictions</h2>\n<p>A query plan is the optimizer’s choice, not a guarantee of speed, and it depends on the data. On a tiny table the optimizer may decide a scan is cheaper than an index, and it’ll be right. Both engines can keep statistics about each table, gathered by the <code dir=\"ltr\">ANALYZE</code> command (PostgreSQL’s autovacuum runs it for you; in SQLite someone has to run it), and use them to estimate how many rows each step returns. When those statistics are stale, plans go wrong in surprising ways, which is one more question to ask the database owner about a query that slowed down “for no reason”.</p>\n<p>Some filters can’t use an ordinary index however you write them. <code dir=\"ltr\">name LIKE '%mat%'</code> has to look inside every value, because a sorted list can’t help you find text in the middle of words. If a search like that is frequent, the answer is a different tool, such as full-text search, rather than another index.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">When to ask for a new index</h2>\n<p>Analysts often don’t own the schema, but you’ll be the one who notices the slow query. Support filters tickets by <code dir=\"ltr\">opened_at</code> constantly, and that column has no index, so the plan for those queries is a SCAN. The request to the database owner would be:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">CREATE</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> INDEX</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> idx_tickets_opened_at</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ON</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets (opened_at);</span></span></code></pre></div>\n<p>Indexes aren’t free. Each one takes storage and must be updated on every insert, update and delete, so a table with ten indexes writes noticeably slower. Ask for indexes on columns that frequent queries filter, join or sort on, and that narrow the result a lot. An index on <code dir=\"ltr\">status</code>, with five values, rarely helps; an index on a date or an id usually does. For a multi-column index, put the column you test with <code dir=\"ltr\">=</code> first and the range column second: <code dir=\"ltr\">(customer_id, order_date)</code> serves “customer 42’s orders in 2025” with one search.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>The same idea in PostgreSQL</span></p>\n<p>PostgreSQL’s <code dir=\"ltr\">EXPLAIN</code> shows a tree with estimated costs and row counts (“Seq Scan” is its full scan, “Index Scan” its search), and <code dir=\"ltr\">EXPLAIN ANALYZE</code> actually runs the query and adds real timings. The rules about sargable filters and join-key indexes carry over unchanged.</p>\n</aside>\n<p>Fast queries are only useful if people can read them. Next: the style habits and dialect differences that make your SQL reviewable and portable.</p>\n","toc":[{"id":"sec-1","text":"What an index is","level":2},{"id":"sec-2","text":"Reading a query plan","level":2},{"id":"sec-3","text":"Plans are predictions","level":2},{"id":"sec-4","text":"When to ask for a new index","level":2}],"takeaways":["An index is a sorted copy of one or more columns with pointers back to the rows, so the database can jump to matching values instead of reading every row.","In EXPLAIN QUERY PLAN, SEARCH … USING INDEX means the index narrowed the work; SCAN means every row (or every index entry) was read.","Wrapping an indexed column in a function or arithmetic, like date(order_date), usually stops the index from being used; compare the bare column with a range instead.","Indexes speed up reads but cost storage and slow down every insert and update, so add them for frequent, selective filters and join keys."],"further":[{"title":"SQLite EXPLAIN QUERY PLAN","url":"https://www.sqlite.org/eqp.html"},{"title":"SQLite query optimizer overview","url":"https://www.sqlite.org/optoverview.html"},{"title":"PostgreSQL using EXPLAIN","url":"https://www.postgresql.org/docs/current/using-explain.html"}],"quiz":[{"q":"<p><code dir=\"ltr\">orders</code> has an index on <code dir=\"ltr\">order_date</code>. Which filter for orders on 28 November 2025 can use it as a SEARCH?</p>","options":[{"text":"<code dir=\"ltr\">WHERE date(order_date) = '2025-11-28'</code>","why":"The index stores raw <code dir=\"ltr\">order_date</code> values, not <code dir=\"ltr\">date(order_date)</code>, so the database has to compute the function for every row."},{"text":"<code dir=\"ltr\">WHERE strftime('%Y-%m-%d', order_date) = '2025-11-28'</code>","why":"Same problem in a different function; any expression wrapped around the column hides it from the index."},{"text":"<code dir=\"ltr\">WHERE order_date &gt;= '2025-11-28' AND order_date &lt; '2025-11-29'</code>","why":"Correct. The bare column is compared with constants, so the database can seek to the start of the range in the sorted index and stop at the end."}],"answer":2},{"q":"<p>A plan shows <code dir=\"ltr\">SCAN orders USING COVERING INDEX idx_orders_date</code>. What does that mean?</p>","options":[{"text":"Every entry of the index was read, but the table itself wasn’t touched because the index held every column the query needed.","why":"Correct. It’s cheaper than scanning the table, since the index is smaller, but it’s still a full pass rather than a targeted SEARCH."},{"text":"The index was used to jump straight to the matching rows.","why":"That would be SEARCH. SCAN always means reading everything, here the whole index."},{"text":"The query is broken and will return wrong results.","why":"Query plans describe speed, never correctness; the result is the same either way."},{"text":"SQLite created a temporary index for this query.","why":"A temporary index shows up as AUTOMATIC INDEX in the plan; this names a permanent one."}],"answer":0},{"q":"<p>An analytics database gets a million new web sessions a day and is queried a few times a day by <code dir=\"ltr\">source</code>. Someone proposes indexes on all nine columns of <code dir=\"ltr\">web_sessions</code>. What’s the best response?</p>","options":[{"text":"Agree; more indexes always make queries faster.","why":"Indexes only help queries that filter, join or sort on their columns, and each one adds work to every insert."},{"text":"Refuse all indexes because writes matter more than reads.","why":"Too absolute. An index that serves a frequent, selective filter is usually worth its write cost."},{"text":"Index <code dir=\"ltr\">customer_id</code> only, because ids are always the best index.","why":"The best index matches how the table is queried. Nothing in the scenario filters by customer."},{"text":"Index the columns that real, frequent queries filter or join on, check the plans, and skip the rest.","why":"Correct. Every index has a write and storage cost, so add the ones that serve actual queries and confirm with EXPLAIN QUERY PLAN that they’re used."}],"answer":3},{"q":"<p>In PostgreSQL, what does <code dir=\"ltr\">EXPLAIN ANALYZE</code> do that <code dir=\"ltr\">EXPLAIN</code> doesn’t?</p>","options":[{"text":"It rewrites the query to use better indexes.","why":"Neither command changes the query; they only report on it."},{"text":"It actually runs the query and reports real row counts and timings next to the estimates.","why":"Correct. That makes it the tool for finding where estimates are wrong, and a reason to be careful running it on statements that modify data."},{"text":"It analyses the query for syntax errors only.","why":"Syntax is checked by every command; ANALYZE adds execution, not validation."}],"answer":1}],"exercise":{"kind":"sql","starter":"-- Orders per day over the Black Friday weekend (Fri 28 Nov to Mon 1 Dec 2025)\nSELECT\n  date(order_date) AS day,\n  COUNT(*) AS orders\nFROM orders\nWHERE order_date BETWEEN '2025-11-28' AND '2025-12-01'\nGROUP BY day\nORDER BY day;","solution":"SELECT\n  date(order_date) AS day,\n  COUNT(*) AS orders\nFROM orders\nWHERE order_date >= '2025-11-28'\n  AND order_date < '2025-12-02'\nGROUP BY day\nORDER BY day;","orderMatters":true,"title":"A fast and correct weekend filter","prompt":"<p>Mark’s dashboard shows orders per day for the Black Friday weekend, <strong>Friday 28 November to Monday 1 December 2025 inclusive</strong> (all statuses).</p>\n<p>Return <code dir=\"ltr\">day</code> (‘YYYY-MM-DD’) and <code dir=\"ltr\">orders</code>, sorted by <code dir=\"ltr\">day</code>. Order matters. The starter’s filter is index-friendly but drops a whole day.</p>\n<p>Fix it so the result is complete <strong>and</strong> the filter still uses <code dir=\"ltr\">idx_orders_date</code>: run your query with <code dir=\"ltr\">EXPLAIN QUERY PLAN</code> in front and check that the plan says SEARCH, not SCAN.</p>\n","hints":["Which day is missing from the starter’s output, and why does BETWEEN lose it?","Wrapping the column, as in <code dir=\"ltr\">date(order_date) BETWEEN ...</code>, would fix the count but turn the plan into a SCAN.","Use a half-open range on the bare column: <code dir=\"ltr\">order_date &gt;= '2025-11-28' AND order_date &lt; '2025-12-02'</code>."],"explanation":"<p><code dir=\"ltr\">BETWEEN '2025-11-28' AND '2025-12-01'</code> stops at midnight at the start of 1 December, so Cyber Monday disappears. <code dir=\"ltr\">date(order_date) BETWEEN ...</code> returns the right rows but makes SQLite compute <code dir=\"ltr\">date()</code> for every order, a full scan. The half-open range keeps the bare indexed column on one side of each comparison, so the plan reads <code dir=\"ltr\">SEARCH orders USING COVERING INDEX idx_orders_date</code>, and it includes all of Monday: 15, 12, 11 and 4 orders.</p>\n","checkLabels":{}},"words":888,"readMinutes":4}