Skip to main content

FinOps: Cost per Query, Cost per Pipeline

Level 10: Distributed Compute & Data Operationsmedium26 minbytes-scanned billingcost attributionrepeated-scan detectionDPU-hour pipeline costGROUP BY rollupsshare of total

Attribute serverless spend to queries, users, and pipelines from usage metadata, then find the repeated-scan leak that quietly doubles a bill.

The bill is a query you can write

Level 6 taught you the serverless cost model as a fact: a query engine like Athena charges for the bytes it reads, not the rows it returns. FinOps is what happens when that fact becomes a weekly habit. Somebody asks "why did the platform bill jump 40 percent", and the useful answer is not a shrug, it is a result set. Every managed engine writes its own usage metadata, and every usage table has the same two things in it: a unit of work, and a meter reading.

The two meters that cover most of a data platform:

  • Bytes scanned. Athena bills $5.00 per TB scanned, with a 10 MB minimum per query. The minimum is not trivia. It means a query against a two-megabyte dimension table still costs the same as a ten-megabyte one, so a tile that refreshes every thirty seconds against a tiny table is billed thousands of times for bytes it never read.
  • DPU-hours. A Glue ETL job bills $0.44 per DPU-hour (a DPU is 4 vCPU and 16 GB), with a one-minute minimum per run. A job that finishes in eleven seconds still bills a minute, which is why "one Glue job per table" looks cheap in the code review and expensive on the invoice.

Cost per unit of work is then plain SQL: take the meter reading, apply the floor, apply the rate.

Table
Four meters, one query shape: reading times rate, grouped by whatever you want to attribute the money to.
meterbilled unitthe audit querythe usual leak
bytes scannedMAX(bytes_scanned, 10 MB) at $5/TBcost per query, per user, per tableone dashboard rescanning raw files
DPU-hoursDPU-hours at $0.44, 1-minute floorcost per pipeline per monthmany tiny jobs paying the minimum
storageGB-month per storage classspend by prefix and classno lifecycle rule, nothing ages out
requestsGET/PUT per 1,000requests per partitionmillions of small files
Four meters, one query shape: reading times rate, grouped by whatever you want to attribute the money to.

The leak hides from the ranking

Here is the part that catches people. Rank your queries by cost and you get the ten biggest single scans, which are almost always somebody's one-off backfill or an analyst exploring. Those are visible, defensible, and usually not the problem.

The problem is the query nobody notices, run over and over. AWS's own documented case is a team whose Athena spend doubled in a quarter, with about 70 percent of it repeated scans of the same data. No single run looked alarming. The fingerprint that finds it is a hash of the normalized SQL text, carried in this lesson's seed as query_hash: identical text means an identical hash, so the same query run four hundred times collapses into one row. Group by the hash instead of by the query and the shape of the bill changes completely.

Once you have the hash you also have the fix menu, in order of effort: cache or materialize the dashboard's result so it reads a small table instead of the raw one; convert and partition the source so each run reads a fraction of the bytes (the next lesson prices that); or set a workgroup bytes-scanned limit, which is the platform's built-in brake and cancels a query that goes past a byte budget, so you pay only for what it scanned before the cancel instead of for the full runaway scan. Be precise about that last one in an interview: the limit caps the blast radius, it does not zero it. A cancelled query is still billed for the bytes it read on the way to being cancelled, exactly like one you cancel by hand.

Attribution is the deliverable, not the number

A FinOps finding that stops at "we spent $12,400" changes nothing. The finding that changes something names an owner: this hash, run by this service account, against this table, is 69 percent of the bill. That is why every query in this lesson groups by something a human is responsible for.

Common mistake: billing on bytes_scanned directly and forgetting the minimum. It makes no difference to your top spenders and a very large difference to any account whose workload is thousands of tiny queries, which is exactly the account that shows up asking why a "free" dashboard has a bill.

Interview nuance: when you are asked "how would you reduce our warehouse costs", the answer that lands is not a list of tips, it is a method: attribute first (per query, per hash, per user, per pipeline), then fix the biggest attributed line, then put a guardrail on it so it cannot come back. Naming the repeated-scan pattern, and grouping the history by a hash of the normalized query text as the way you would find it, is the detail that separates you from a candidate reciting "use Parquet".

On a real platform this differs. Here you query one small query_history table in SQLite, and the hash arrives pre-computed. Not every platform hands you one. Snowflake does, as QUERY_HASH and QUERY_PARAMETERIZED_HASH on its query history views. Athena, BigQuery and Redshift do not: their history gives you the query text, the bytes scanned and the status, so you normalize the text and hash it yourself, or simply group by the text. That is the only step this seed takes for you. Otherwise the same reasoning runs against information_schema-style query history, the Cost and Usage Report, or CloudWatch job metrics. On BigQuery the meter is on-demand bytes billed per job in INFORMATION_SCHEMA.JOBS; on Microsoft Fabric it is capacity units in the metrics app. The rate and the column names move, the query shape does not.

Sample data for this example
CREATE TABLE query_history (
  query_id      TEXT,
  user_name     TEXT,
  query_hash    TEXT,     -- fingerprint of the normalized SQL text; identical text = identical hash
  table_name    TEXT,
  bytes_scanned INTEGER,  -- bytes the engine actually read; 1 TB = 1,000,000,000,000 bytes here
  started_at    TEXT
);
INSERT INTO query_history (query_id, user_name, query_hash, table_name, bytes_scanned, started_at) VALUES
  ('q-101', 'marco_ds',    'h2a900', 'raw_events_csv',       1120000000000, '2026-03-01 08:12:00'),
  ('q-102', 'dana_ops',    'h3b117', 'raw_events_csv',        950000000000, '2026-03-01 11:47:00'),
  ('q-103', 'priya_de',    'h4c220', 'raw_clickstream_json',  820000000000, '2026-03-01 15:03:00'),
  ('q-104', 'marco_ds',    'h5d331', 'raw_clickstream_json',  710000000000, '2026-03-02 09:26:00'),
  ('q-105', 'dana_ops',    'h6e442', 'raw_events_csv',        620000000000, '2026-03-02 14:11:00'),
  ('q-106', 'priya_de',    'h7f553', 'events_parquet',        540000000000, '2026-03-02 17:58:00'),
  ('q-107', 'marco_ds',    'h8a664', 'events_parquet',        490000000000, '2026-03-03 08:40:00'),
  ('q-108', 'dana_ops',    'h9b775', 'orders_parquet',        450000000000, '2026-03-03 10:22:00'),
  ('q-109', 'priya_de',    'h1c886', 'orders_parquet',        420000000000, '2026-03-03 13:19:00'),
  ('q-110', 'marco_ds',    'h2d997', 'events_parquet',        390000000000, '2026-03-03 16:05:00'),
  ('q-201', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-01 02:00:00'),
  ('q-202', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-01 14:00:00'),
  ('q-203', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-02 02:00:00'),
  ('q-204', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-02 14:00:00'),
  ('q-205', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-03 02:00:00'),
  ('q-206', 'etl_service', 'h4e2b8', 'orders_parquet',         45000000000, '2026-03-03 14:00:00'),
  ('q-301', 'bi_service',  'h8f3c2', 'dim_customer',               2400000, '2026-03-02 06:30:00'),
  ('q-302', 'bi_service',  'h8f3c2', 'dim_customer',               2400000, '2026-03-02 09:30:00'),
  ('q-303', 'bi_service',  'h9e4b1', 'dim_customer',               2400000, '2026-03-02 12:30:00'),
  ('q-304', 'bi_service',  'h8f3c2', 'dim_customer',               2400000, '2026-03-02 15:30:00'),
  ('q-305', 'bi_service',  'h8f3c2', 'dim_customer',               2400000, '2026-03-03 06:30:00'),
  ('q-306', 'bi_service',  'h9e4b1', 'dim_customer',               2400000, '2026-03-03 09:30:00'),
  ('q-307', 'bi_service',  'h8f3c2', 'dim_customer',               2400000, '2026-03-03 12:30:00'),
  ('q-308', 'bi_service',  'h9e4b1', 'dim_customer',               2400000, '2026-03-03 15:30:00'),
  ('q-401', 'analyst_tmp', 'h5a6d4', 'dim_customer',               6100000, '2026-03-03 11:12:00'),
  ('q-402', 'analyst_tmp', 'h3c9e7', 'dim_product',                 900000, '2026-03-03 11:20:00'),
  ('q-403', 'analyst_tmp', 'h5a6d4', 'dim_customer',               6100000, '2026-03-03 11:26:00'),
  ('q-404', 'analyst_tmp', 'h5a6d4', 'dim_customer',               6100000, '2026-03-03 11:41:00'),
  ('q-405', 'analyst_tmp', 'h5a6d4', 'dim_customer',               6100000, '2026-03-03 11:58:00');
-- The revenue dashboard refreshed every 20 minutes from 06:00 to 19:00 on 2026-03-02: forty runs
-- of one query text, so one query_hash, against the unpartitioned raw table, 380 GB every time.
INSERT INTO query_history (query_id, user_name, query_hash, table_name, bytes_scanned, started_at)
WITH RECURSIVE refresh(n) AS (
  SELECT 0 UNION ALL SELECT n + 1 FROM refresh WHERE n < 39
)
SELECT 'q-d' || substr('00' || n, -2), 'bi_dashboard', 'h6d0a1', 'raw_events_csv', 380000000000,
       '2026-03-02 ' || substr('0' || (6 + (n * 20) / 60), -2) || ':' || substr('0' || ((n * 20) % 60), -2) || ':00'
FROM refresh;
Worked example (SQL)
-- Where the money went, by table: the raw unpartitioned files dominate the bill.
SELECT table_name,
       COUNT(*) AS queries,
       ROUND(SUM(5.0 * MAX(bytes_scanned, 10000000) / 1000000000000.0), 4) AS spend_usd
FROM query_history
GROUP BY table_name
ORDER BY spend_usd DESC;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the ten most expensive queries, as (query_id, user_name, cost_usd), most expensive first, over query_history(query_id, user_name, query_hash, table_name, bytes_scanned, started_at).

Bill each query at $5.00 per TB scanned, where 1 TB is 1,000,000,000,000 bytes, with a 10 MB minimum: a query that read fewer than 10,000,000 bytes is still billed for 10,000,000. Round cost_usd to 4 decimals and break ties on query_id.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns every query_hash run at least 5 times with its run count, total cost, and share of total spend, as (query_hash, runs, total_cost_usd, pct_of_spend), biggest spender first, over the same query_history table.

Bill every query at $5.00 per TB scanned with the same 10 MB minimum. pct_of_spend is that hash's total as a percentage of the spend across every row in the table, not just the repeated ones. Round total_cost_usd to 4 decimals and pct_of_spend to 2.

2 hints and 1 automated check are waiting in the workspace.