[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"post-content-how-i-cut-database-query-time-mysql-indexing":3},"\u003Cp>I'll never forget the moment my production database curled up and died.\u003C\u002Fp>\n\n\u003Cp>It was a Tuesday. I'd just rolled out a \"simple\" feature — a dashboard showing all orders from the last 90 days, grouped by customer, with their total spend and most recent purchase date. Sounded innocent enough. The query looked fine in my local dev environment with its 200 test records.\u003C\u002Fp>\n\n\u003Cp>Production had 847,000 orders.\u003C\u002Fp>\n\n\u003Cp>The page took 23 seconds to load. My phone started buzzing. The CEO emailed me. I sat there staring at \u003Ccode>EXPLAIN\u003C\u002Fcode> output I didn't fully understand, watching a \u003Cstrong>full table scan on a table with half a million rows\u003C\u002Fstrong>.\u003C\u002Fp>\n\n\u003Cp>That was the day I decided to actually learn how MySQL indexes work. Not just \"add an index on the column you're filtering by\" — which is what every tutorial tells you and is honestly wrong half the time.\u003C\u002Fp>\n\n\u003Cp>Here's what I wish someone had explained to me years earlier.\u003C\u002Fp>\n\n\u003Ch2>What an Index Actually Does (Skip This If You Know)\u003C\u002Fh2>\n\n\u003Cp>An index is a sorted copy of a subset of your table's data. MySQL uses it to find rows without scanning every row. Think of it like the index at the back of a textbook — instead of reading every page to find where \"database indexing\" is discussed, you check the index, get \"p. 142-148\", and flip straight there.\u003C\u002Fp>\n\n\u003Cp>Without an index, MySQL reads every row (a \"full table scan\"). With an index, it does a B-tree search — which for a table with a million rows means about \u003Cstrong>20 lookups instead of a million\u003C\u002Fstrong>.\u003C\u002Fp>\n\n\u003Cp>That's the theory. Here's where it gets interesting.\u003C\u002Fp>\n\n\u003Ch2>The Single Biggest Mistake I Made\u003C\u002Fh2>\n\n\u003Cp>I used to index individual columns. Every column I might filter on got its own index. That's what the tutorials said, right?\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Bad: separate indexes\nCREATE INDEX idx_customer_id ON orders (customer_id);\nCREATE INDEX idx_status ON orders (status);\nCREATE INDEX idx_created_at ON orders (created_at);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Then I'd write a query like this:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">SELECT COUNT(*), SUM(total)\nFROM orders\nWHERE status = 'completed'\n  AND created_at > NOW() - INTERVAL 30 DAY\n  AND customer_id = 8472;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>And MySQL would pick \u003Cstrong>one\u003C\u002Fstrong> index — usually the most selective one — and then filter the rest row by row. My three separate indexes were barely better than no indexes at all for this query.\u003C\u002Fp>\n\n\u003Ch3>What I Should Have Done: Composite Indexes\u003C\u002Fh3>\n\n\u003Cp>A \u003Cstrong>composite index\u003C\u002Fstrong> (or multi-column index) covers multiple columns in a single B-tree. The order of columns in the index declaration matters enormously.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Good: composite index\nCREATE INDEX idx_customer_status_date ON orders (customer_id, status, created_at);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>This single index can satisfy the entire \u003Ccode>WHERE\u003C\u002Fcode> clause. MySQL walks the B-tree once — narrow by \u003Ccode>customer_id\u003C\u002Fcode>, then by \u003Ccode>status\u003C\u002Fcode>, then by \u003Ccode>created_at\u003C\u002Fcode> — and it already has the exact rows it needs.\u003C\u002Fp>\n\n\u003Cp>\u003Cstrong>But\u003C\u002Fstrong> there's a catch.\u003C\u002Fp>\n\n\u003Ch2>The Leftmost Prefix Rule\u003C\u002Fh2>\n\n\u003Cp>Composite indexes only work if your query conditions use the columns \u003Cstrong>from left to right\u003C\u002Fstrong>. If you have \u003Ccode>INDEX (a, b, c)\u003C\u002Fcode>, these queries use the index:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">WHERE a = 1           -- ✓ uses index\nWHERE a = 1 AND b = 2 -- ✓ uses index\nWHERE a = 1 AND c = 3 -- ✓ uses index for 'a', but not 'c'\nWHERE b = 2           -- ✗ full table scan\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>This means you need to think carefully about which columns to put first. The general rule:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>Equality columns first\u003C\u002Fstrong> — put \u003Ccode>WHERE x = ?\u003C\u002Fcode> columns before range columns\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Most selective first\u003C\u002Fstrong> — the column that eliminates the most rows should be leftmost\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Range columns last\u003C\u002Fstrong> — once MySQL hits a range condition (\u003Ccode>>\u003C\u002Fcode>, \u003Ccode>\u003C\u003C\u002Fcode>, \u003Ccode>BETWEEN\u003C\u002Fcode>, \u003Ccode>LIKE\u003C\u002Fcode> without leading wildcard), it stops using further columns in the index for filtering\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>How I Actually Fixed That Dashboard Query\u003C\u002Fh2>\n\n\u003Cp>Here's the real query that had been killing my dashboard:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">SELECT\n  c.name,\n  COUNT(o.id) AS order_count,\n  SUM(o.total) AS total_spent,\n  MAX(o.created_at) AS last_order\nFROM customers c\nJOIN orders o ON o.customer_id = c.id\nWHERE o.status = 'completed'\n  AND o.created_at >= '2026-04-16'\nGROUP BY c.id\nORDER BY total_spent DESC\nLIMIT 50;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>After understanding composite indexes, I created:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">CREATE INDEX idx_orders_completed_date\n  ON orders (status, created_at, customer_id);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Query time: \u003Cstrong>23 seconds → 0.4 seconds\u003C\u002Fstrong>.\u003C\u002Fp>\n\n\u003Cp>Was that the end? No. I also needed a covering index.\u003C\u002Fp>\n\n\u003Ch2>Covering Indexes: The Secret Weapon\u003C\u002Fh2>\n\n\u003Cp>A covering index contains \u003Cstrong>all the columns a query needs\u003C\u002Fstrong>, so MySQL never has to touch the actual table — it reads everything from the index itself. This is significantly faster because indexes are more compact and cached more aggressively.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">-- Covering index for the join + aggregation\nCREATE INDEX idx_orders_covering\n  ON orders (status, created_at, customer_id, total, id);\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Now MySQL reads everything from the index. No table lookups at all. The query dropped to \u003Cstrong>0.08 seconds\u003C\u002Fstrong>.\u003C\u002Fp>\n\n\u003Cp>I keep this exact pattern stored in \u003Ca href=\"https:\u002F\u002Fdevspera.com\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> so I never forget it. Honestly, having a well-organized snippet library for query patterns like this saves me more time than any AI assistant when I'm in the zone.\u003C\u002Fp>\n\n\u003Ch2>When Indexes Hurt\u003C\u002Fh2>\n\n\u003Cp>Indexes aren't free. Every index you add:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>Slows down writes\u003C\u002Fstrong> — MySQL has to update every index on INSERT, UPDATE, and DELETE\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Takes disk space\u003C\u002Fstrong> — indexes can be larger than the table itself\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Can confuse the optimizer\u003C\u002Fstrong> — too many options and MySQL may pick the wrong one\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>\u003Cstrong>My rule of thumb:\u003C\u002Fstrong> no more than 5-6 indexes per table for a typical OLTP workload. If you're adding more, you're probably missing a composite index that could replace three singles.\u003C\u002Fp>\n\n\u003Cp>Tools like \u003Ccode>pt-index-usage\u003C\u002Fcode> from Percona Toolkit can analyze your slow query log and tell you which indexes are never used. I run this quarterly and delete the dead weight.\u003C\u002Fp>\n\n\u003Ch2>Most Useful Things I Check First When a Query Is Slow\u003C\u002Fh2>\n\n\u003Ch3>1. Run EXPLAIN\u003C\u002Fh3>\n\n\u003Cp>Just put \u003Ccode>EXPLAIN\u003C\u002Fcode> before your \u003Ccode>SELECT\u003C\u002Fcode>. Look for \u003Ccode>type: ALL\u003C\u002Fcode> (full table scan), \u003Ccode>rows\u003C\u002Fcode> (how many rows MySQL examined), and \u003Ccode>Extra: Using filesort\u003C\u002Fcode> or \u003Ccode>Using temporary\u003C\u002Fcode> (both bad).\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">EXPLAIN SELECT ...\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch3>2. Check for implicit type conversion\u003C\u002Fh3>\n\n\u003Cp>This one bit me hard:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">-- If customer_id is INT but you pass a string\nSELECT * FROM orders WHERE customer_id = '8472';\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>MySQL casts \u003Cem>every row's\u003C\u002Fem> \u003Ccode>customer_id\u003C\u002Fcode> to a string before comparing. Index ignored. Always match your column types.\u003C\u002Fp>\n\n\u003Ch3>3. Avoid SELECT * in queries with joins\u003C\u002Fh3>\n\n\u003Cp>Grabbing every column forces MySQL to read the table even when a covering index would suffice. Be explicit about what you need.\u003C\u002Fp>\n\n\u003Ch3>4. Watch out for ORDER BY + LIMIT\u003C\u002Fh3>\n\n\u003Cp>If MySQL can't satisfy the \u003Ccode>ORDER BY\u003C\u002Fcode> from an index, it reads all matching rows, sorts them, then trims to \u003Ccode>LIMIT\u003C\u002Fcode>. That's a lot of wasted work. Add an index that matches the sort order.\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-sql\">-- This benefits from INDEX (status, created_at)\nSELECT * FROM orders\nWHERE status = 'completed'\nORDER BY created_at DESC\nLIMIT 20;\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Ch2>The Mental Model That Finally Made It Click\u003C\u002Fh2>\n\n\u003Cp>Someone explained indexes to me like a phone book (dating myself a bit here, but bear with me).\u003C\u002Fp>\n\n\u003Cp>If you need to find \"John Smith\" in a phone book:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>No index\u003C\u002Fstrong> — Read every name on every page. Good luck.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Index on last name\u003C\u002Fstrong> — Go to the \"S\" section, find Smith, read all Smith entries. Fast.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Composite index on (last_name, first_name)\u003C\u002Fstrong> — Go directly to Smith, John. One entry. Done.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>Same thing. The phone book \u003Cem>is\u003C\u002Fem> a clustered index ordered by last name, then first name. If you want to find everyone named \"John\" regardless of last name, that phone book is useless — you're back to scanning every page.\u003C\u002Fp>\n\n\u003Cp>That's why column order in composite indexes matters so much.\u003C\u002Fp>\n\n\u003Ch2>Tools That Help\u003C\u002Fh2>\n\n\u003Cp>Beyond EXPLAIN and \u003Ccode>pt-index-usage\u003C\u002Fcode>, I rely on a few things:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>MySQL Workbench\u003C\u002Fstrong> — visual EXPLAIN is way easier to read than raw output\u003C\u002Fli>\n\u003Cli>\u003Cstrong>phpMyAdmin\u003C\u002Fstrong> — quick \u003Ccode>EXPLAIN\u003C\u002Fcode> and index management without SSH\u003C\u002Fli>\n\u003Cli>\u003Cstrong>My own snippet collection\u003C\u002Fstrong> — I keep my most-used indexing patterns, EXPLAIN cheat sheets, and migration templates saved locally. \u003Ca href=\"https:\u002F\u002Fdevspera.com\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> is perfect for this because it's local-first and doesn't phone home with my production schema\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>What I'd Tell My Younger Self\u003C\u002Fh2>\n\n\u003Cp>Don't add indexes blindly. Start with the queries your app actually runs — check your slow query log, pick the worst offenders, and build composite indexes that cover them completely. Delete indexes nothing uses. Repeat once a quarter.\u003C\u002Fp>\n\n\u003Cp>That's it. That's 90% of the performance gain with 10% of the effort.\u003C\u002Fp>\n\n\u003Cp>What's the slowest query you've ever had to fix? I'm genuinely curious — drop it in the comments or save the pattern to your own snippet library. I've got mine waiting in \u003Ca href=\"https:\u002F\u002Fdevspera.com\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> if you need a place to start organizing yours.\u003C\u002Fp>\n",1787133717921]