How to read a database query plan (and stop the $400 surprise bill)

ยท once found a $879 dashboard hiding in a cron schedule๐Ÿ’ธ with a $879 dashboard receipt, not a lecture

Published August 26, 2026 ยท Updated August 28, 2026

The 60-second version

  • Warehouses bill for data scanned, not answers returned. A five-number chart can scan 100 GB if it reads every column of every row.
  • Three habits cut costs 10โ€“100x: name your columns, filter the partition date, aggregate before joining.
  • Check the estimate before you run. The dry-run byte figure is the price tag โ€” read it like one.
bottom line: scan less data, pay less money

Modern cloud databases and analytics warehouses bill by data scanned and compute consumed, not by how complex your SQL looks or how small the final answer is.

A query that returns just five summary numbers can easily scan 100 gigabytes of historical data if written carelessly, while a query returning ten thousand rows can cost fractions of a cent if it is properly filtered.

This is how growth teams and marketing departments wake up to a $400 to $1,200 surprise bill at the end of the month on a dashboard that "only shows a few basic charts": an unoptimized query is silently scanning an entire warehouse every 15 minutes in the background.

The Dashboard Auto-Refresh Cost Trap

Cloud Cost Hazard
Single Query Scan50 GB per execution$0.31 per single run
Refresh FrequencyEvery 15 minutes96 automated runs every 24 hours
Total Data Scanned4,800 GB / dayโ‰ˆ141 TB scanned per month
Monthly Cost Impact$878.91 / monthFor a single 4-chart executive dashboard
A query that costs pennies in testing becomes a major line-item cost when scheduled on a 15-minute cron interval.
LIVE ยท DRAG ITPrice your own dashboard refresh
50
96

Every 15 minutes is 96 runs a day

$6.25
Cost per run
$0.31
Cost per month
$878.91
SCHEDULED PAIN

A pennies-per-run query on a tight refresh loop is a monthly line item. Widen the interval or point the tile at a rollup.

live mathsestimates are fine โ€” this is a what-if sandbox

Library vs filing cabinet

To understand why database bills explode, imagine asking a librarian: "What was our total revenue on Tuesday?" pennies per run, hundreds per month

Row store ยท reads whole folders

๐Ÿ“ The row-based approach

Traditional CRM and MySQL. The librarian opens every customer folder one by one โ€” name, address, phone number, purchase total โ€” then adds up Tuesday.

  • Reads every column of every row it touches
  • Built for fetching one order at a time
  • Analytics re-reads the whole folder for one number
Column store ยท reads two shelves

๐Ÿ—„๏ธ The columnar approach

Modern warehouses. Every column lives on its own shelf โ€” all order dates in one file, all order amounts in another. Tuesday needs two shelves only.

  • Touches only the columns you name
  • Ignores addresses, device logs, email strings
  • That is exactly why SELECT * hurts here

If you write SELECT *, you force the database to pull down every single shelf in the library, even if your final chart only displays one number.

The LIMIT 10 Myth: In modern analytical databases, adding LIMIT 10 to SELECT * does not reduce the data scanned or the cost of the query. The database must still read all columns across storage before applying the limit.


Four costly mistakes and fixes

1. SELECT * on Wide Marketing & Event Tables

Marketing datasets (like web event exports, CRM activity logs, and ad conversion tables) often have 50 to 100+ columns, including large nested JSON strings and user agent metadata.

-- โŒ EXPENSIVE: Scans all 80 columns in the table (50+ GB scanned)
SELECT * 
FROM analytics_events 
WHERE event_name = 'purchase';

-- โœ… CHEAP: Scans only the 3 columns needed (2 GB scanned โ€” 96% cost reduction)
SELECT 
  event_date, 
  user_id, 
  order_value
FROM analytics_events
WHERE event_name = 'purchase';

2. Forgetting to Filter on the Partition Date

A partitioned table physically separates records into daily or monthly folders. When you filter by the partition date, the query engine skips 99% of historical data. When you forget the filter, it scans every record from the day the company was founded.

-- โŒ EXPENSIVE: Scans 3 years of company transaction history
SELECT SUM(order_total) 
FROM customer_orders;

-- โœ… CHEAP: Scans only yesterday's partition
SELECT SUM(order_total) 
FROM customer_orders
WHERE order_date = CURRENT_DATE() - 1;

3. The Function Wrapping Trap (Breaking Partition Pruning)

Applying SQL functions to a timestamp column can silently prevent the database engine from recognizing partitions:

-- โš ๏ธ RISKY: Function wrapping might bypass partition index on some engines
WHERE DATE(created_timestamp) = '2026-08-26'

-- โœ… SAFE: Use explicit date/timestamp boundary filters
WHERE created_timestamp >= '2026-08-26 00:00:00' 
  AND created_timestamp <  '2026-08-27 00:00:00'

4. Joining Before Aggregating (The Fan-Out Disaster)

Joining two raw, row-level tables (e.g. 5 million click events joined to 1 million orders on a shared timestamp) forces the database to perform massive in-memory data shuffles.

The Fix: Always aggregate both tables to the target grain (e.g. daily totals) first, then join the small aggregated summary tables:

-- โœ… CHEAP & FAST: Aggregate each side to daily totals, then join
WITH daily_ad_costs AS (
  SELECT 
    DATE(spend_date) AS day, 
    SUM(spend_amount) AS total_spend
  FROM ad_channel_spend
  WHERE spend_date >= '2026-08-01'
  GROUP BY 1
),

daily_sales AS (
  SELECT 
    DATE(order_created_at) AS day, 
    SUM(order_total) AS total_revenue
  FROM orders
  WHERE order_status = 'completed' 
    AND order_created_at >= '2026-08-01'
  GROUP BY 1
)

SELECT 
  s.day,
  c.total_spend,
  s.total_revenue,
  ROUND(s.total_revenue / NULLIF(c.total_spend, 0), 2) AS blended_roas
FROM daily_sales s
LEFT JOIN daily_ad_costs c USING (day)
ORDER BY s.day DESC;

Read a query plan in 60 seconds

When a query is slow or expensive, open its Execution Details / Query Plan tab in your database editor. Three numbers tell you almost everything:

Bytes scanned

The primary driver of cost. Tens of gigabytes for a routine marketing report means a missing date filter or a SELECT * somewhere upstream.

Slot time vs clock time

Ten minutes of compute slot time inside two seconds of clock time means parallel workers did the job well. The gap is parallelism, not waste.

Spill to disk

Spill during a join or GROUP BY means the join key created more intermediate rows than memory holds. Shrink the fan-out, not the query.


Guardrails against runaway bills

Your pre-run guardrails make these muscle memory โ†’

  • Check the dry-run estimate before clicking Run โ€” most SQL consoles and BI tools show what the query will process before execution. That line is the price tag.
  • Set a maximum-bytes-billed safeguard (for example 10 GB in your project settings or connection options) so an accidental 500 GB scan fails immediately instead of billing.
  • Audit scheduled dashboard frequencies โ€” a Monday-review dashboard refreshing every 15 minutes instead of every 4 hours costs 16x as much, a 93.75% saving for zero lost decisions.

One question before the FAQ. 30 seconds, no grading

A query returns 5 rows, but the validator says it will scan 80 GB. What do you do?

Takeaways for your next report

  • Cloud warehouses bill for bytes scanned and compute used โ€” a five-number answer can cost dollars while a 10,000-row answer costs cents.
  • Name only the columns you need, always filter the partition date, and aggregate tables before joining them.
  • LIMIT never reduces cost, and function-wrapped dates or forgotten partition filters silently re-enable full scans.
  • Check the dry-run estimate before every run, cap bytes billed, and match dashboard refresh rates to decision rates.
stick this on your Monday report
Free tool

BigQuery Cost Estimator

Estimate what a BigQuery query will cost before you run it, from validator bytes, with a table-scan simulator and a paste-your-query cost audit.


Frequently Asked Questions

Why did my database query cost money if it returned 0 rows?

Cloud databases bill for the amount of data they had to inspect to confirm there were 0 matching rows. If you search for an obscure customer ID across a 500 GB table without an index or partition filter, the engine must scan all 500 GB to prove the ID does not exist.

What is the difference between partitioning and clustering?

  • Partitioning divides tables into coarse segments (e.g., by day or month).
  • Clustering sorts data inside each partition by specific columns (e.g., sorting events by user_id or campaign_id), allowing the engine to skip non-relevant blocks within a single day.

Summary

Controlling database costs does not require a degree in data engineering. By following three golden rules:

  • Select only the specific columns you need (no SELECT *).
  • Always filter by partition dates.
  • Aggregate tables before joining.

You can run lightning-fast analytics across millions of marketing and CRM records while keeping your monthly database invoice under control.

Chinmay Raibagkar

Chinmay Raibagkar

About author โ†’

Founder of DataLens AI. He helps non-technical teams read their ad and database numbers with confidence โ€” which number to trust, what to do next, and what to ignore.

Glossary terms referenced