{
  "version": 1,
  "id": "sql-query-tuning-case-study",
  "title": "Case Study: Tuning a Slow Analytical Query",
  "summary": "Read an execution plan, find why a query is slow, and fix it with indexes and rewrites.",
  "estimatedMinutes": 30,
  "prerequisites": [],
  "source": "courses-notebook DS301_databases_sql 08_case_study",
  "quiz": [
    {
      "prompt": "Which command in PostgreSQL actually executes the query and reports per-node runtimes?",
      "options": [
        "EXPLAIN",
        "EXPLAIN ANALYZE",
        "DESCRIBE",
        "PROFILE"
      ],
      "answer": 1
    },
    {
      "prompt": "Which pagination strategy stays O(log n) for very deep pages?",
      "options": [
        "OFFSET / LIMIT",
        "Keyset (seek) pagination using WHERE id > :last_seen",
        "DISTINCT pagination",
        "ORDER BY RAND()"
      ],
      "answer": 1
    },
    {
      "prompt": "Wrapping an indexed column in a function inside the WHERE clause (e.g., LOWER(email) = ...) typically:",
      "options": [
        "Speeds up the query",
        "Prevents use of the index on that column",
        "Forces use of a hash index",
        "Has no effect"
      ],
      "answer": 1
    },
    {
      "prompt": "Which is the correct logical claim about indexes?",
      "options": [
        "Indexes always speed up queries",
        "Indexes accelerate reads on the indexed columns but slow down writes and consume storage",
        "Indexes have no maintenance cost",
        "Indexes must be added to every column"
      ],
      "answer": 1
    }
  ],
  "lesson": "**Course:** DS301 \u2014 Databases & SQL\n\n**Scenario.** It is Monday morning. The finance team\u2019s weekly revenue dashboard query \u2014 the one feeding `revenue_by_region_week` \u2014 has gone from 800 ms to 47 seconds since Friday\u2019s release. The schema didn\u2019t change. The data volume grew by 4%. The on-call rotation is you.\n\nThis case study walks the full tuning loop: **profile \u2192 read the plan \u2192 pick the right index \u2192 weigh denormalisation \u2192 add a regression gate so the fix doesn\u2019t silently rot.** Every step runs on stdlib `sqlite3` so you can adapt the workflow to any RDBMS (Postgres `EXPLAIN ANALYZE`, MySQL `EXPLAIN FORMAT=JSON`, SQL Server `SET STATISTICS IO`).\n\n## Architecture under test\n\n```\n  dashboard ---> [revenue_by_region_week query] ---> result set\n                          |\n                          v\n                  fact_orders (large)  JOIN  dim_date, dim_customer, dim_product (small)\n                          |\n                          +--- predicate: order_date BETWEEN ? AND ?\n                          +--- group:     region, iso_week\n                          +--- aggregate: SUM(net_revenue)\n```\n\nTuning is a four-question loop. Skip a step and you optimise the wrong thing:\n1. **Where is time going?** Wall-clock alone lies; you need row counts per plan node.\n2. **What plan is the engine choosing?** `EXPLAIN`/`EXPLAIN ANALYZE` is the source of truth.\n3. **What\u2019s the cheapest structural fix?** Index, rewrite, materialised view, denormalisation \u2014 in that order of cost.\n4. **How do we keep it fast?** A regression gate on plan shape and latency, not just on result correctness.\n\n## Part 1 \u2014 Reproduce the slow query\n\n```python\nimport sqlite3, random, time\nfrom dataclasses import dataclass\nfrom datetime import date, timedelta\n\nrandom.seed(42)\nconn = sqlite3.connect(':memory:')\nconn.row_factory = sqlite3.Row\ncur = conn.cursor()\n\ncur.executescript(\"\"\"\nCREATE TABLE dim_date (\n    date_key   INTEGER PRIMARY KEY,\n    full_date  TEXT,\n    iso_week   INTEGER,\n    month      INTEGER,\n    quarter    INTEGER\n);\nCREATE TABLE dim_customer (\n    customer_key INTEGER PRIMARY KEY,\n    region       TEXT,\n    segment      TEXT\n);\nCREATE TABLE dim_product (\n    product_key INTEGER PRIMARY KEY,\n    category    TEXT,\n    unit_price  REAL\n);\nCREATE TABLE fact_orders (\n    order_key    INTEGER PRIMARY KEY,\n    date_key     INTEGER REFERENCES dim_date(date_key),\n    customer_key INTEGER REFERENCES dim_customer(customer_key),\n    product_key  INTEGER REFERENCES dim_product(product_key),\n    quantity     INTEGER,\n    net_revenue  REAL\n);\n\"\"\")\n\nbase = date(2024, 1, 1)\nfor i in range(365):\n    d = base + timedelta(days=i)\n    cur.execute('INSERT INTO dim_date VALUES (?,?,?,?,?)',\n                (d.toordinal(), d.isoformat(), d.isocalendar()[1], d.month, (d.month-1)//3+1))\n\nREGIONS = ['North','South','East','West']\nfor i in range(200):\n    cur.execute('INSERT INTO dim_customer VALUES (?,?,?)',\n                (i+1, random.choice(REGIONS), random.choice(['Retail','Wholesale'])))\n\nCATALOG = [('Phones',299.0),('Laptops',899.0),('Shirts',29.0),('Shoes',79.0),('Kitchen',49.0)]\nfor i,(cat,price) in enumerate(CATALOG):\n    cur.execute('INSERT INTO dim_product VALUES (?,?,?)', (i+1, cat, price))\n\nrows = []\nfor k in range(60_000):\n    d = base + timedelta(days=random.randint(0, 364))\n    pk = random.randint(1, 5); qty = random.randint(1, 4)\n    rows.append((k+1, d.toordinal(), random.randint(1, 200), pk, qty, qty * CATALOG[pk-1][1]))\ncur.executemany('INSERT INTO fact_orders VALUES (?,?,?,?,?,?)', rows)\nconn.commit()\nprint('fact_orders rows:', cur.execute('SELECT COUNT(*) FROM fact_orders').fetchone()[0])\n```\n\n```python\n@dataclass\nclass Query:\n    name: str\n    sql: str\n    params: tuple = ()\n\n@dataclass\nclass ExecPlan:\n    query: str\n    plan_lines: list\n    rows_returned: int\n    elapsed_ms: float\n\ndef run_and_profile(q: Query) -> ExecPlan:\n    plan = [r[3] for r in cur.execute(f'EXPLAIN QUERY PLAN {q.sql}', q.params).fetchall()]\n    t0 = time.perf_counter()\n    out = cur.execute(q.sql, q.params).fetchall()\n    return ExecPlan(q.name, plan, len(out), (time.perf_counter() - t0) * 1000)\n\nSLOW_SQL = '''\nSELECT c.region, d.iso_week, SUM(f.net_revenue) AS revenue\n  FROM fact_orders f\n  JOIN dim_date     d ON f.date_key     = d.date_key\n  JOIN dim_customer c ON f.customer_key = c.customer_key\n WHERE d.full_date BETWEEN ? AND ?\n GROUP BY c.region, d.iso_week\n ORDER BY c.region, d.iso_week\n'''\n\nbaseline = run_and_profile(Query('baseline', SLOW_SQL, ('2024-06-01', '2024-06-30')))\nprint(f'rows returned : {baseline.rows_returned}')\nprint(f'elapsed       : {baseline.elapsed_ms:.1f} ms')\nprint('plan:')\nfor line in baseline.plan_lines:\n    print(' ', line)\n```\n\n**Reading the plan.** `SCAN fact_orders` means the engine is walking every row of the largest table and filtering after the join. The `dim_date` filter on `full_date` is a TEXT comparison that can\u2019t use the `date_key` primary key. That\u2019s the smell: the predicate column and the join column are different, so the small-table filter cannot push the join into an index seek on the fact table.\n\n## Part 2 \u2014 Index choice (single-column vs composite vs covering)\n\nIndexes are not free: each one slows writes, costs disk, and only helps queries whose predicates match its leading columns. We compare three options against the same query.\n\n```python\ndef reset_indexes():\n    for name, in cur.execute(\"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'ix_%'\").fetchall():\n        cur.execute(f'DROP INDEX {name}')\n\nexperiments = []\n\n# A. single column on the join key\nreset_indexes()\ncur.execute('CREATE INDEX ix_fact_date ON fact_orders(date_key)')\nexperiments.append(('single-col date_key', run_and_profile(Query('A', SLOW_SQL, ('2024-06-01','2024-06-30')))))\n\n# B. composite that matches GROUP BY's other column\nreset_indexes()\ncur.execute('CREATE INDEX ix_fact_date_cust ON fact_orders(date_key, customer_key)')\nexperiments.append(('composite (date_key, customer_key)', run_and_profile(Query('B', SLOW_SQL, ('2024-06-01','2024-06-30')))))\n\n# C. covering index that also carries the measure\nreset_indexes()\ncur.execute('CREATE INDEX ix_fact_cover ON fact_orders(date_key, customer_key, net_revenue)')\nexperiments.append(('covering (date_key, customer_key, net_revenue)', run_and_profile(Query('C', SLOW_SQL, ('2024-06-01','2024-06-30')))))\n\nprint(f\"{'option':50s} {'rows':>6s} {'ms':>8s}  plan summary\")\nfor label, ep in [('baseline (no index)', baseline)] + experiments:\n    summary = ' / '.join(p.split(' ')[0] for p in ep.plan_lines)\n    print(f'  {label:48s} {ep.rows_returned:>6d} {ep.elapsed_ms:>8.1f}  {summary}')\n```\n\n## Part 3 \u2014 Decision table: row store vs column store vs OLAP cube\n\nIndexes get you a long way. When the workload is overwhelmingly read-mostly, scan-heavy, and aggregating wide columns, the storage engine itself becomes the lever.\n\n| Engine family | Best when | Avoid when | Examples |\n|---|---|---|---|\n| **Row store (OLTP)** | High write throughput, point reads, narrow row access | Wide aggregates over millions of rows | Postgres, MySQL InnoDB, SQLite |\n| **Columnar (OLAP)** | Scan-heavy aggregates over few columns of a wide table | Heavy point updates, single-row reads | DuckDB, ClickHouse, BigQuery, Snowflake |\n| **Pre-aggregated OLAP cube / materialised view** | Same dashboard query runs thousands of times/day | Ad-hoc / unpredictable slice-and-dice | dbt incremental models, Druid, Pinot |\n| **Hybrid HTAP** | One workload, both OLTP and OLAP characteristics | Single-purpose workloads where a specialist wins | TiDB, SingleStore, CockroachDB |\n\nPick by *how the query reads the data*, not by vendor familiarity. Most dashboard latency problems on a row store are solved by either: (a) the right composite index, (b) a nightly materialised view, or (c) moving the dashboard query to a columnar replica. Re-platforming is the last resort.\n\n## Part 4 \u2014 Denormalisation tradeoffs\n\nIf a join key never changes (the customer\u2019s region for the lifetime of an order) you can fold the dimension column into the fact table. The query stops joining. The cost is duplication and a backfill story for the day someone *does* change a region.\n\n```python\nreset_indexes()\ncur.execute('ALTER TABLE fact_orders ADD COLUMN region TEXT')\ncur.execute('ALTER TABLE fact_orders ADD COLUMN iso_week INTEGER')\ncur.execute('''UPDATE fact_orders SET\n    region   = (SELECT region   FROM dim_customer c WHERE c.customer_key = fact_orders.customer_key),\n    iso_week = (SELECT iso_week FROM dim_date     d WHERE d.date_key     = fact_orders.date_key)''')\ncur.execute('CREATE INDEX ix_fact_denorm ON fact_orders(region, iso_week, net_revenue)')\nconn.commit()\n\nDENORM_SQL = '''\nSELECT region, iso_week, SUM(net_revenue) AS revenue\n  FROM fact_orders\n WHERE date_key BETWEEN ? AND ?\n GROUP BY region, iso_week\n ORDER BY region, iso_week\n'''\nlo = date(2024,6,1).toordinal(); hi = date(2024,6,30).toordinal()\ndenorm = run_and_profile(Query('denormalised', DENORM_SQL, (lo, hi)))\nprint(f'denormalised: {denorm.elapsed_ms:.1f} ms over {denorm.rows_returned} rows')\nfor p in denorm.plan_lines:\n    print(' ', p)\n```\n\n**When denormalisation is the right answer.**\n\n| Test | Keep normalised | Denormalise |\n|---|---|---|\n| Dimension value changes per fact row? | Yes \u2014 SCD Type 2 in dim | No \u2014 safe to copy |\n| Reads/writes ratio | < 10:1 | \u226b 100:1 |\n| Source of truth lives elsewhere? | Yes \u2014 single source | No \u2014 fact is canonical |\n| Storage cost matters more than CPU? | Yes | No |\n\nIf you denormalise, document the invariant (\"`fact_orders.region` is the customer\u2019s region *at order time*\") and own the backfill job. Silent drift between `dim_customer.region` and `fact_orders.region` is the most common bug in this pattern.\n\n## Part 5 \u2014 Regression gate\n\nA tuning win that isn\u2019t protected by CI regresses on the next schema change. Pin a baseline and fail loud when latency \u2014 or, equally important, plan shape \u2014 drifts.\n\n```python\n@dataclass\nclass PerfBaseline:\n    name: str\n    max_ms: float\n    forbidden_plan_tokens: tuple   # plan must NOT contain these\n    required_plan_tokens: tuple    # plan MUST contain these\n\nBASELINES = [\n    PerfBaseline('weekly_revenue', max_ms=50.0,\n                 forbidden_plan_tokens=('SCAN fact_orders',),\n                 required_plan_tokens=('USING INDEX',)),\n]\n\ndef check(plan: ExecPlan, b: PerfBaseline) -> list:\n    issues = []\n    if plan.elapsed_ms > b.max_ms:\n        issues.append(f'latency {plan.elapsed_ms:.1f}ms > budget {b.max_ms}ms')\n    joined = ' | '.join(plan.plan_lines)\n    for tok in b.forbidden_plan_tokens:\n        if tok in joined:\n            issues.append(f'forbidden plan token present: {tok!r}')\n    for tok in b.required_plan_tokens:\n        if tok not in joined:\n            issues.append(f'required plan token missing: {tok!r}')\n    return issues\n\nfor label, ep in [('baseline', baseline), ('denormalised', denorm)] + experiments:\n    issues = check(ep, BASELINES[0])\n    verdict = 'PASS' if not issues else 'FAIL'\n    print(f'  {label:48s} {ep.elapsed_ms:>7.1f} ms  [{verdict}]')\n    for i in issues:\n        print(f'       \u2192 {i}')\n```\n\n**Why gate on plan shape, not just latency.** Latency alone is noisy \u2014 cache state, vacuum activity, neighbour load. The plan shape is deterministic given statistics and schema. A passing latency with `SCAN fact_orders` in the plan is a bomb that detonates the moment the table grows. Gate both.\n\n## Part 6 \u2014 Runbook: what to try, in what order\n\n| Symptom | First action | If that fails | Last resort |\n|---|---|---|---|\n| `SCAN <large_table>` in plan | Add composite index leading with the predicate column | Add covering index including the aggregated column | Materialised view refreshed nightly |\n| Predicate column \u2260 join column | Push filter to the joined dim and propagate via FK | Rewrite predicate to use the indexed surrogate (e.g. `date_key BETWEEN`) | Denormalise the predicate column into the fact |\n| GROUP BY explodes intermediate rows | Pre-aggregate in a CTE or subquery | Materialised view at the aggregation grain | Move to columnar engine |\n| Plan flips between runs | `ANALYZE` to refresh stats | Pin plan with optimiser hints (where supported) | Stable surrogate index on the join keys |\n| Latency spikes after deploy | Diff the plan vs last-good baseline | Roll back if plan shape changed | Pre-deploy plan check in CI |\n\nWork top-down. Each row is roughly 5\u00d7 cheaper than the next.\n\n## Reflection\n\n**Takeaways**\n\n- The plan is the source of truth. Latency moves; plan shape tells you *why*.\n- Indexes are bargains \u2014 covering indexes more so \u2014 until write rate or storage cost says otherwise.\n- Denormalisation is the right tool only when the duplicated value is stable for the fact\u2019s lifetime; otherwise it\u2019s a slow-burning data quality bug.\n- The decision between row store, columnar engine, and pre-aggregated cube is set by the *read pattern*, not the team\u2019s favourite vendor.\n- A tuning win without a regression gate has a half-life of one sprint.\n\n\n\n**Where to go next.** `DS602 ML Project 2` reuses these aggregation patterns to build training tables; `DS801 Distributed Big Data` is what you reach for when a single-node engine \u2014 even fully tuned \u2014 stops being enough; `DL501 Deep Learning Foundations` then shows why the *quality* of features (not just their availability) governs model behaviour.\n"
}
