<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>koul1sh</title><description>Notes and writing by Vansh Koul.</description><link>https://koul1sh.github.io/</link><language>en</language><atom:link href="https://koul1sh.github.io/rss.xml" rel="self" type="application/rss+xml"/><lastBuildDate>Wed, 11 Nov 2026 13:48:21 GMT</lastBuildDate><item><title>Before You Scale It, EXPLAIN It</title><link>https://koul1sh.github.io/posts/getting-more-out-of-your-mysql-instance/</link><guid isPermaLink="true">https://koul1sh.github.io/posts/getting-more-out-of-your-mysql-instance/</guid><description>Getting the most out of your existing MySQL instance by finding and fixing slow queries before reaching for replicas, sharding, or partitioning.</description><pubDate>Tue, 10 Nov 2026 12:00:00 GMT</pubDate><content:encoded>&lt;h2 id=&quot;why-should-we-care-about-slow-queries&quot;&gt;Why should we care about slow queries?&lt;/h2&gt;
&lt;p&gt;The idea is simple: get as much as possible out of your existing database instances before adding another replica, sharding your data, partitioning tables, and whatnot.&lt;/p&gt;
&lt;p&gt;I know these things sound exciting and might even look good on your resume, but they also bring along a fair bit of complexity. More moving parts, more things to maintain, and more things that can go wrong.&lt;/p&gt;
&lt;p&gt;Before we go down that road, shouldn’t we first make sure we’re getting the most out of what we already have?&lt;/p&gt;
&lt;p&gt;A database has limited resources. CPU, memory, disk I/O, connections, all of it. Every query consumes some of these resources. If we’re making the database scan a million rows when it only needs a few hundred, we’re essentially making it do unnecessary work.&lt;/p&gt;
&lt;p&gt;And this adds up. Inefficient queries can occupy connections for longer, consume more CPU and I/O, and reduce the number of requests the database can handle efficiently.&lt;/p&gt;
&lt;p&gt;The idea is to make the database do less unnecessary work so it has more room to do the work that actually matters.&lt;/p&gt;
&lt;h2 id=&quot;what-causes-slow-queries&quot;&gt;What causes slow queries?&lt;/h2&gt;
&lt;p&gt;A few usual suspects:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Poorly written queries:&lt;/strong&gt; Fetching more data than necessary, inefficient joins, unnecessary operations, and so on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing or improper indexes:&lt;/strong&gt; The database ends up scanning far more rows than it needs to because the available indexes don’t align with the query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skewed data:&lt;/strong&gt; Sometimes the data distribution doesn’t play nicely with the query optimizer, causing it to choose a suboptimal execution plan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource contention:&lt;/strong&gt; Lock contention, high concurrency, CPU saturation, and disk I/O bottlenecks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application-level inefficiencies:&lt;/strong&gt; Sometimes the query itself isn’t particularly bad. It’s just being executed thousands of times because of how the application is written.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;how-i-usually-approach-this&quot;&gt;How I usually approach this&lt;/h2&gt;
&lt;p&gt;My investigation generally follows four steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Find the expensive queries.&lt;/strong&gt; Figure out which query patterns are consuming the most resources. Execution time alone doesn’t tell the whole story; frequency and cumulative execution time matter too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Figure out what’s going wrong.&lt;/strong&gt; Look at the execution plan, understand what the database is doing, and identify what could be improved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Find where the query lives in the codebase.&lt;/strong&gt; A query doesn’t magically appear in the database. Trace it back to the service or API that’s generating it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix it and see if it actually helped.&lt;/strong&gt; Make the change, deploy it, and compare the results. No point celebrating an optimization that only looks good on paper.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;finding-the-expensive-queries&quot;&gt;Finding the expensive queries&lt;/h3&gt;
&lt;p&gt;First things first, check your logs.&lt;/p&gt;
&lt;p&gt;Since our databases run on AWS RDS, I usually start with CloudWatch. Head over to Database Insights / Performance Insights and look at the top SQL queries by wait activity.&lt;/p&gt;
&lt;p&gt;There are three metrics I usually pay attention to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Calls/sec:&lt;/strong&gt; How frequently is this query being executed?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Average latency:&lt;/strong&gt; How much time does each execution take on average?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rows read:&lt;/strong&gt; How many rows does the database have to read to execute the query?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Don’t look at these metrics in isolation. It’s mostly the combination of all three that tells you where the actual problem is.&lt;/p&gt;
&lt;p&gt;For example, a query taking 200 ms might not look particularly bad. But if it’s being executed 100 times per second, that’s a lot of database work adding up.&lt;/p&gt;
&lt;p&gt;Similarly, a query might execute only a handful of times but take several seconds and read hundreds of thousands of rows. That’s worth investigating too.&lt;/p&gt;
&lt;p&gt;The idea is to identify queries that are either consuming a disproportionate amount of database resources or contributing significantly to application latency.&lt;/p&gt;
&lt;p&gt;Once you have a shortlist, you know where to start digging instead of randomly opening SQL queries and hoping to find something interesting.&lt;/p&gt;
&lt;h3 id=&quot;now-whats-wrong-with-the-query&quot;&gt;Now, what’s wrong with the query?&lt;/h3&gt;
&lt;p&gt;So you’ve identified an expensive query. What’s next?&lt;/p&gt;
&lt;p&gt;Let’s ask MySQL itself. Put two words before your SELECT query:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;EXPLAIN ANALYZE&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;SELECT&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;*&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; orders&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; customer_id &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;101&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One important thing: &lt;strong&gt;EXPLAIN ANALYZE actually executes the query.&lt;/strong&gt; It doesn’t just show what MySQL plans to do. So be mindful when running it, especially against production databases. A query that takes a few seconds can still put unnecessary load on your database. Avoid blindly running expensive queries during peak traffic.&lt;/p&gt;
&lt;h4 id=&quot;how-do-we-read-the-output&quot;&gt;How do we read the output?&lt;/h4&gt;
&lt;p&gt;Let’s take a simple example.&lt;/p&gt;
&lt;p&gt;Suppose our &lt;code&gt;orders&lt;/code&gt; table contains a few million records, and we run the query above to fetch all orders belonging to a particular customer.&lt;/p&gt;
&lt;p&gt;Imagine MySQL gives us something like this:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Filter: (orders.customer_id = 101)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.120..85.400 rows=120 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Table scan on orders&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.100..78.200 rows=1000000 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Let’s break this down.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Table scan on orders:&lt;/strong&gt; MySQL is scanning the table instead of using an index to locate the relevant rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rows=1000000:&lt;/strong&gt; The scan produced approximately one million rows for the filter operation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filter:&lt;/strong&gt; MySQL applies the &lt;code&gt;customer_id&lt;/code&gt; condition to those rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;rows=120:&lt;/strong&gt; Only 120 rows match our condition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;actual time:&lt;/strong&gt; This shows the observed execution time for the operation. In this example, the scan takes around 78 ms, while the filter operation takes around 85 ms.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We’re scanning a million rows just to retrieve 120. That’s a lot of unnecessary work.&lt;/p&gt;
&lt;p&gt;A suitable index on &lt;code&gt;customer_id&lt;/code&gt; could help:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;INDEX&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#85570F;--1:#FABD2F&quot;&gt;idx_orders_customer_id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; orders(customer_id);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Run EXPLAIN ANALYZE again and compare the execution plans.&lt;/p&gt;
&lt;p&gt;You should now see an index lookup instead of a full table scan, assuming the optimizer considers that index beneficial.&lt;/p&gt;
&lt;p&gt;That’s a much better starting point.&lt;/p&gt;
&lt;p&gt;Also, don’t just look at the &lt;code&gt;rows&lt;/code&gt; value. Pay attention to &lt;code&gt;loops&lt;/code&gt; too. An operation returning a few rows might be executed thousands of times as part of a nested-loop join. That’s where things can get interesting.&lt;/p&gt;
&lt;h4 id=&quot;a-small-but-important-detail-rows--loops&quot;&gt;A small but important detail: rows × loops&lt;/h4&gt;
&lt;p&gt;One thing I always pay attention to in EXPLAIN ANALYZE is the combination of &lt;code&gt;actual rows&lt;/code&gt; and &lt;code&gt;loops&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In MySQL’s tree-format output, actual rows and execution time are reported per loop. So if an operation returns 1 row but executes 50,000 times, that’s potentially 50,000 rows being processed across those loops.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index lookup on orders using idx_customer_id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.020..0.025 rows=1 loops=50000)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;At first glance, returning just one row looks harmless. But look at the number of loops.&lt;/p&gt;
&lt;p&gt;We’re performing that lookup 50,000 times!&lt;/p&gt;
&lt;p&gt;That’s approximately 50,000 rows returned across all those executions. Even though each individual lookup is cheap, the cumulative cost can become significant.&lt;/p&gt;
&lt;p&gt;This is particularly important when investigating nested-loop joins, where an operation on the inner table can execute once for every row produced by the outer table.&lt;/p&gt;
&lt;p&gt;So don’t just look at how many rows an operation returns. Look at how many times it’s executed too.&lt;/p&gt;
&lt;p&gt;A useful rule of thumb: &lt;strong&gt;inspect &lt;code&gt;actual rows × loops&lt;/code&gt; to understand the total number of rows produced by an operation.&lt;/strong&gt; Remember, this isn’t necessarily the number of physical disk reads. InnoDB might already have the relevant pages in its buffer pool.&lt;/p&gt;
&lt;h3 id=&quot;what-about-joins&quot;&gt;What about joins?&lt;/h3&gt;
&lt;p&gt;Let’s take a real example.&lt;/p&gt;
&lt;p&gt;We have two tables:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;package&lt;/code&gt;: One row per package, with columns like &lt;code&gt;franchise_id&lt;/code&gt; and &lt;code&gt;created_on&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;package_tracking&lt;/code&gt;: Tracking information for each package, with columns like &lt;code&gt;estimated_delivery_date&lt;/code&gt;, &lt;code&gt;payment_due_date&lt;/code&gt; and &lt;code&gt;estimated_return_date&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A lot of our queries fetch package details using those tracking dates. One of them looks like this: get all packages of a franchise with an estimated delivery date in the next 30 days.&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;SELECT&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;tracking_number&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package p&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;JOIN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package_tracking pt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;package_id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;franchise_id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;42&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;      &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;BETWEEN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; INTERVAL &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;DAY&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Looks harmless. There’s an index on &lt;code&gt;package.franchise_id&lt;/code&gt; and an index on &lt;code&gt;package_tracking.package_id&lt;/code&gt;. Let’s put &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; in front of it and see what MySQL gives us. Something like this:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Nested loop inner join&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.9..2140 rows=1800 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index lookup on p using idx_package_franchise_id (franchise_id=42)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.5..310 rows=250000 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Filter: (pt.estimated_delivery_date between ...)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.007..0.007 rows=0.0072 loops=250000)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index lookup on pt using idx_package_tracking_package_id (package_id=p.id)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.006..0.006 rows=1 loops=250000)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There are a few things we want to inspect here.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Join order:&lt;/strong&gt; MySQL starts from &lt;code&gt;package&lt;/code&gt; and then goes to &lt;code&gt;package_tracking&lt;/code&gt;. The optimizer decides this order based on its estimates and the available indexes, not on the order we wrote the tables in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Access method:&lt;/strong&gt; Both tables use index lookups. No table scans. On the surface, everything looks fine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Rows and loops:&lt;/strong&gt; This is where it falls apart. The lookup on &lt;code&gt;package&lt;/code&gt; returns 250,000 rows, every package this franchise has ever had. Then the lookup on &lt;code&gt;package_tracking&lt;/code&gt; runs 250,000 times (&lt;code&gt;loops=250000&lt;/code&gt;), once for each of those packages. Only after fetching the tracking row does MySQL apply the date filter, and almost everything gets thrown away. &lt;code&gt;rows=0.0072&lt;/code&gt; per loop × 250,000 loops ≈ 1,800 rows.&lt;/p&gt;
&lt;p&gt;We’re doing 250,000 lookups to return 1,800 rows.&lt;/p&gt;
&lt;h4 id=&quot;understanding-nested-loop-joins&quot;&gt;Understanding nested-loop joins&lt;/h4&gt;
&lt;p&gt;This is how a nested-loop join works:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fetch M rows from table A.&lt;/li&gt;
&lt;li&gt;For every row fetched from A, look for matching rows in table B.&lt;/li&gt;
&lt;li&gt;Repeat until all M rows have been processed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The cost depends mostly on M. Here, A is &lt;code&gt;package&lt;/code&gt;, and M is every package of the franchise, because the only filter MySQL can apply to &lt;code&gt;package&lt;/code&gt; is &lt;code&gt;franchise_id&lt;/code&gt;. The date filter lives in the other table, so it can only be applied after the join.&lt;/p&gt;
&lt;p&gt;You might think: why not start from &lt;code&gt;package_tracking&lt;/code&gt; instead, using an index on &lt;code&gt;estimated_delivery_date&lt;/code&gt;? MySQL can do that, but then it has the opposite problem. It fetches packages due in the next 30 days for &lt;em&gt;every&lt;/em&gt; franchise, then looks up each package just to discard the ones from other franchises. Either way, one of the two filters gets applied too late.&lt;/p&gt;
&lt;p&gt;So there are two levers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Reduce the number of rows fetched from the first table.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make sure the matching rows in the second table can be found efficiently.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The second one is already taken care of. Each lookup into &lt;code&gt;package_tracking&lt;/code&gt; uses an index and is cheap. The problem is purely that we do it 250,000 times.&lt;/p&gt;
&lt;h4 id=&quot;reducing-the-rows-from-the-first-table&quot;&gt;Reducing the rows from the first table&lt;/h4&gt;
&lt;p&gt;The question I asked was: is there anything on the &lt;code&gt;package&lt;/code&gt; table that tells me a package can’t possibly be delivered in the next 30 days?&lt;/p&gt;
&lt;p&gt;There was. A package created years ago isn’t going to have an estimated delivery date next month. In our case, it was safe to say that any package with an upcoming delivery date was created within the last year.&lt;/p&gt;
&lt;p&gt;So I added a filter on &lt;code&gt;created_on&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;SELECT&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;tracking_number&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package p&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;JOIN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package_tracking pt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;package_id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;franchise_id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;42&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;created_on&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; INTERVAL &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;YEAR&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;      &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;BETWEEN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; INTERVAL &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;DAY&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;With a composite index to support it:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;INDEX&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#85570F;--1:#FABD2F&quot;&gt;idx_package_franchise_created&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package(franchise_id, created_on);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Now MySQL can narrow down the packages before it starts the join:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Nested loop inner join&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.8..410 rows=1800 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index range scan on p using idx_package_franchise_created&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;over (franchise_id = 42 AND created_on &amp;gt;= ...)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.4..60 rows=45000 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Filter: (pt.estimated_delivery_date between ...)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.007..0.007 rows=0.04 loops=45000)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index lookup on pt using idx_package_tracking_package_id (package_id=p.id)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;           &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.006..0.006 rows=1 loops=45000)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Same 1,800 rows, but 45,000 lookups instead of 250,000.&lt;/p&gt;
&lt;p&gt;The filter looks redundant. Logically, the date condition on &lt;code&gt;package_tracking&lt;/code&gt; already decides which rows come back. But MySQL can’t apply that condition until after the join. The &lt;code&gt;created_on&lt;/code&gt; condition can be applied &lt;em&gt;before&lt;/em&gt; it, using an index. The idea is to filter out unnecessary rows as early as possible rather than carrying them through the join.&lt;/p&gt;
&lt;p&gt;One big caveat: &lt;strong&gt;this filter is only correct because of a business rule.&lt;/strong&gt; If a package can ever be created more than a year before its delivery date, this query silently drops it. No error, just missing data. So make sure the assumption actually holds, and leave a comment next to the query explaining why the filter is there. Otherwise someone will either remove it as “useless” or keep it after the rule changes.&lt;/p&gt;
&lt;p&gt;Also, don’t blindly add filters everywhere. MySQL’s optimizer can already push down and derive some conditions on its own. Check whether the extra condition actually changes the execution plan and reduces the work being done.&lt;/p&gt;
&lt;p&gt;That’s why EXPLAIN ANALYZE is so useful. It helps us move beyond guessing and understand what MySQL is actually doing.&lt;/p&gt;
&lt;h3 id=&quot;sometimes-the-problem-isnt-the-query&quot;&gt;Sometimes, the problem isn’t the query&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;created_on&lt;/code&gt; trick worked because we had a column on &lt;code&gt;package&lt;/code&gt; that loosely tracked the dates we cared about.&lt;/p&gt;
&lt;p&gt;But what if you don’t have one? Or what if the query is on &lt;code&gt;payment_due_date&lt;/code&gt;, and some payments are due years after the package was created?&lt;/p&gt;
&lt;p&gt;You’ve optimized the query, added appropriate indexes, investigated the join order, and checked the execution plan. And yet, the query is still doing way too much work.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://koul1sh.github.io/_astro/napoleon-battlefield.BUfRXL63_ZQ8Eou.webp&quot; alt=&quot;Napoleon looking at the battlefield: “There is nothing we can do”&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1200&quot; height=&quot;800&quot;&gt;&lt;/p&gt;
&lt;p&gt;Maybe it’s time to look beyond the query itself.&lt;/p&gt;
&lt;p&gt;Look at what the query actually needs: packages of &lt;em&gt;one franchise&lt;/em&gt; with &lt;em&gt;a date in some range&lt;/em&gt;. The franchise is on one table, and the date is on another. No index can span two tables, so no single index can answer this query. MySQL will always have to over-fetch on one side and filter on the other.&lt;/p&gt;
&lt;p&gt;That’s a schema problem, not a query problem.&lt;/p&gt;
&lt;p&gt;The fix is to get both columns into the same table. For example, add &lt;code&gt;franchise_id&lt;/code&gt; to &lt;code&gt;package_tracking&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ALTER&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;TABLE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package_tracking&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ADD&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; COLUMN franchise_id &lt;/span&gt;&lt;span style=&quot;--0:#AE3A03;--1:#FE8019&quot;&gt;INT&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;NOT NULL&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;CREATE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;INDEX&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#85570F;--1:#FABD2F&quot;&gt;idx_tracking_franchise_edd&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package_tracking(franchise_id, estimated_delivery_date);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Now the query can filter on &lt;code&gt;package_tracking&lt;/code&gt; alone:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;sql&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;SELECT&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;tracking_number&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package_tracking pt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;JOIN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; package p&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;    &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;ON&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;package_id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;WHERE&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;franchise_id&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;42&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;  &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;pt&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;estimated_delivery_date&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;      &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;BETWEEN&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;AND&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; CURDATE&lt;/span&gt;&lt;span style=&quot;--0:#6B5F56;--1:#A89984&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#396B4C;--1:#8EC07C&quot;&gt;+&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; INTERVAL &lt;/span&gt;&lt;span style=&quot;--0:#8F3F71;--1:#D3869B&quot;&gt;30&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt; &lt;/span&gt;&lt;span style=&quot;--0:#9D0006;--1:#FB5D4A&quot;&gt;DAY&lt;/span&gt;&lt;span style=&quot;--0:#3C3836;--1:#EBDBB2&quot;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;And the plan becomes:&lt;/p&gt;
&lt;div class=&quot;expressive-code&quot;&gt;&lt;figure class=&quot;frame&quot;&gt;&lt;figcaption class=&quot;header&quot;&gt;&lt;/figcaption&gt;&lt;pre data-language=&quot;text&quot;&gt;&lt;code&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Nested loop inner join&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.3..25 rows=1800 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Index range scan on pt using idx_tracking_franchise_edd&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;over (franchise_id = 42 AND ... &amp;lt;= estimated_delivery_date &amp;lt;= ...)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.2..4 rows=1800 loops=1)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;-&amp;gt; Single-row index lookup on p using PRIMARY (id=pt.package_id)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class=&quot;ec-line&quot;&gt;&lt;div class=&quot;code&quot;&gt;&lt;span class=&quot;indent&quot;&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;--0:#3c3836;--1:#ebdbb2&quot;&gt;(actual time=0.010..0.010 rows=1 loops=1800)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;MySQL reads exactly the 1,800 tracking rows it needs, and does 1,800 primary key lookups. No business-rule assumptions, and it works the same way for &lt;code&gt;payment_due_date&lt;/code&gt; and &lt;code&gt;estimated_return_date&lt;/code&gt; with their own composite indexes.&lt;/p&gt;
&lt;p&gt;Of course, this isn’t free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;We’re duplicating data.&lt;/strong&gt; &lt;code&gt;franchise_id&lt;/code&gt; now lives in two places, and both need to stay in sync. Luckily, a package rarely changes franchise, so this is a fairly cheap duplication. Duplicating something that changes often would be a very different story.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It needs a migration.&lt;/strong&gt; Add the column, backfill it in batches for existing rows, and update every code path that inserts tracking rows. On a large table, adding a column and building indexes needs some care too.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every index has a write cost.&lt;/strong&gt; Three composite indexes, one per date column, means every insert and update on &lt;code&gt;package_tracking&lt;/code&gt; has a bit more work to do.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Whether it’s worth it depends on how often these queries run and how much they cost. If date-based queries like these are all over your application, it probably is.&lt;/p&gt;
&lt;p&gt;This is just one example. Other common ones:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A frequently accessed attribute might be buried inside a large JSON column.&lt;/li&gt;
&lt;li&gt;A list of IDs might be stored as a comma-separated string, which no index can help with.&lt;/li&gt;
&lt;li&gt;A dashboard might aggregate millions of rows on every page load, when a summary table would do.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Schema changes introduce migration complexity, consistency concerns and additional maintenance. But there are cases where no amount of query rewriting will compensate for a data model that doesn’t align with the application’s access patterns.&lt;/p&gt;
&lt;p&gt;That’s when it’s worth stepping back and asking whether the problem is really the query, or the way we’ve modelled the data in the first place.&lt;/p&gt;
&lt;h3 id=&quot;now-lets-find-where-the-query-lives-in-our-codebase&quot;&gt;Now, let’s find where the query lives in our codebase&lt;/h3&gt;
&lt;p&gt;We’ve identified the expensive query and have a fair idea of what’s happening inside the database. Now comes the next question: where is this query being triggered from?&lt;/p&gt;
&lt;p&gt;A database query doesn’t magically appear. Somewhere in our application, there’s code responsible for executing it.&lt;/p&gt;
&lt;p&gt;This is where I’d start looking:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Find the source:&lt;/strong&gt; Search for the query in your codebase. If it’s generated dynamically or through an ORM, use the query logs or tracing information to identify the service and code path responsible for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check how frequently it’s executed:&lt;/strong&gt; Is it being called once per API request, inside a loop, or multiple times for the same data?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Look for unnecessary database calls:&lt;/strong&gt; Are we fetching data that we don’t need? Could multiple queries be combined into one? Are we running into an N+1 query problem?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check pagination and filtering:&lt;/strong&gt; Are we fetching thousands of records when the application only needs a handful? Are we doing unnecessary processing in the application that the database could handle more efficiently?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For example, imagine an API that fetches 100 orders and then executes another query for each order to fetch its customer details.&lt;/p&gt;
&lt;p&gt;That’s potentially 101 database queries for a single API request!&lt;/p&gt;
&lt;p&gt;Depending on the use case, we might be able to fetch the required information using a single JOIN instead.&lt;/p&gt;
&lt;p&gt;The exact approach will vary depending on your application architecture, ORM and codebase. The important thing is to understand not just how expensive a query is, but also why the application is executing it in the first place.&lt;/p&gt;
&lt;p&gt;Sometimes the best database optimization isn’t changing the SQL at all. It’s fixing the application code that’s generating it.&lt;/p&gt;
&lt;h3 id=&quot;fix-it-deploy-it-and-verify&quot;&gt;Fix it, deploy it and verify&lt;/h3&gt;
&lt;p&gt;We’ve identified the expensive query, understood its execution plan, figured out what needs to change, and traced it back to our application.&lt;/p&gt;
&lt;p&gt;Now it’s time to fix it.&lt;/p&gt;
&lt;p&gt;Make the required changes, whether that’s adding an index, rewriting the query, changing the application logic, or modifying the schema.&lt;/p&gt;
&lt;p&gt;But we’re not done yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always verify the improvement after deploying the change.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Go back to the same metrics you used to identify the problem:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Average latency:&lt;/strong&gt; Did the query become faster?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rows read:&lt;/strong&gt; Are we scanning fewer rows?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Calls/sec:&lt;/strong&gt; Has the query frequency changed?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database load:&lt;/strong&gt; Are we consuming fewer resources?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application latency:&lt;/strong&gt; Did the API response time actually improve?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run EXPLAIN ANALYZE again and compare the execution plans wherever appropriate.&lt;/p&gt;
&lt;p&gt;Also, keep an eye on the side effects. Adding indexes can improve read performance but introduce additional overhead for writes. Changing the schema can introduce consistency and migration concerns.&lt;/p&gt;
&lt;p&gt;Don’t optimize one query at the expense of the entire workload.&lt;/p&gt;
&lt;p&gt;And that’s pretty much it. Database optimization is an iterative process. Find the expensive queries, understand why they’re expensive, fix the underlying problem, and verify that things actually improved.&lt;/p&gt;
&lt;p&gt;No amount of clever SQL or fancy infrastructure matters if the database isn’t doing its job efficiently.&lt;/p&gt;
&lt;h2 id=&quot;a-few-final-thoughts&quot;&gt;A few final thoughts&lt;/h2&gt;
&lt;p&gt;Scaling is sometimes necessary. But before adding replicas, sharding tables or throwing more resources at the database, it’s worth spending some time understanding what’s actually slowing it down.&lt;/p&gt;
&lt;p&gt;I’ve seen cases where a few changes to a query or an index were enough to make a noticeable difference. No new infrastructure, no complicated setup. Just fixing what was already there.&lt;/p&gt;
&lt;p&gt;And that’s really the point of this whole exercise. Before making the database bigger, make sure it isn’t doing unnecessary work.&lt;/p&gt;
&lt;p&gt;Happy optimizing!&lt;/p&gt;</content:encoded><dc:creator>Vansh Koul</dc:creator><category>mysql</category><category>databases</category><category>performance</category></item></channel></rss>