Where the Money Went
A medium SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.
- Domain
- SQL
- Difficulty
- medium
- Seniority
- L4
- Asked in
Problem
We reconcile each team's budget allocation in cost_allocs against the actual charge in cloud_costs for the same service in the same billing month, prorating every pairing to an hourly figure by multiplying the allocated amount by the actual cost and dividing by the hours in a year. Return each service with its total prorated cost, keeping only the services whose reconciled total tops 10,000.
Allocated on paper. Spent for real. Reconcile the two.
Tables
cloud_costs
Columns: cost_id INTEGER, provider TEXT, svc_name TEXT, region TEXT, amount REAL, acct_id TEXT, bill_date TEXT
| cost_id | provider | svc_name | region | amount | acct_id | bill_date |
|---|---|---|---|---|---|---|
| 543 | GCP | S3 | us-west-2 | 19.33 | acct-1001 | 2026-02-01 |
| 586 | Azure | RDS | eu-west-1 | 37.16 | acct-1002 | 2026-03-01 |
| 629 | aws | Lambda | ap-southeast-1 | 54.99 | acct-1003 | 2026-04-01 |
| 672 | gcp | BigQuery | us-central1 | 72.82 | acct-1004 | 2026-05-01 |
| 715 | AWS | Cloud Run | europe-west1 | 90.65 | acct-1005 | 2026-06-01 |
| 758 | GCP | Blob Storage | us-east-1 | 108.48 | acct-1006 | 2026-07-01 |
| 801 | Azure | Cosmos DB | us-west-2 | 126.31 | acct-1007 | 2026-08-01 |
| 844 | aws | CloudFront | eu-west-1 | 144.14 | acct-1008 | 2026-09-01 |
| 887 | gcp | Pub/Sub | ap-southeast-1 | 161.97 | NULL | 2026-01-01 |
| 930 | AWS | EC2 | us-central1 | 179.8 | acct-1010 | 2026-02-01 |
cost_allocs
Columns: alloc_id INTEGER, team_name TEXT, svc_name TEXT, region TEXT, amount REAL, period TEXT, acct_id TEXT, category TEXT
| alloc_id | team_name | svc_name | region | amount | period | acct_id | category |
|---|---|---|---|---|---|---|---|
| 129 | data-eng | S3 | us-west-2 | 83.21 | 2026-02 | acct-101 | storage |
| 158 | ml-team | RDS | eu-west-1 | 156.42 | 2026-03 | acct-102 | network |
| 187 | frontend | Lambda | ap-south-1 | 229.63 | 2026-04 | acct-103 | database |
| 216 | backend | BigQuery | us-central1 | -120.5 | 2026-05 | acct-104 | ml |
| 245 | devops | CloudFront | europe-west1 | 376.05 | 2026-06 | acct-105 | other |
| 274 | security | EKS | ap-southeast-1 | 449.26 | 2026-07 | acct-106 | compute |
| 303 | mobile | SQS | eu-central-1 | 522.47 | 2026-08 | acct-107 | storage |
| 332 | Platform | EC2 | us-east-1 | 595.68 | 2026-09 | NULL | network |
| 361 | DATA-ENG | S3 | us-west-2 | 668.89 | 2026-01 | acct-109 | database |
| 390 | platform | RDS | eu-west-1 | 742.1 | 2026-02 | acct-110 | ml |
batch_jobs
Columns: job_id INTEGER, job_name TEXT, status TEXT, rows_done INTEGER, started TEXT, ended TEXT, priority INTEGER, retries INTEGER
| job_id | job_name | status | rows_done | started | ended | priority | retries |
|---|---|---|---|---|---|---|---|
| 1047 | user_export | running | 97 | 2026-02-02 01:00:00 | 2026-02-02 03:11:00 | 3 | 0 |
| 1094 | data_cleanup | failed | 194 | 2026-03-03 02:00:00 | 2026-03-03 04:22:00 | 6 | 0 |
| 1141 | email_blast | queued | 291 | 2026-04-04 03:00:00 | 2026-04-04 05:33:00 | 9 | 0 |
| 1188 | index_rebuild | canceled | 388 | 2026-05-05 04:00:00 | NULL | 2 | 0 |
| 1235 | cache_warm | Completed | 485 | 2026-06-06 05:00:00 | 2026-06-06 07:55:00 | 5 | 0 |
| 1282 | log_archive | FAILED | NULL | 2026-07-07 06:00:00 | 2026-07-07 08:06:00 | 8 | 0 |
| 1329 | metric_agg | completed | 679 | 2026-08-08 07:00:00 | 2026-08-08 09:17:00 | 1 | 0 |
| 1376 | backup_db | running | 776 | 2026-09-09 08:00:00 | NULL | 4 | 0 |
| 1423 | sync_elastic | failed | 873 | 2026-01-10 09:00:00 | 2026-01-10 11:39:00 | 7 | 5 |
| 1470 | daily_report | queued | 970 | 2026-02-11 10:00:00 | 2026-02-11 12:50:00 | 0 | 0 |
Expected output
| svc_name | prorated_cost |
|---|---|
| BigQuery | 12913.290949178083 |
| EC2 | 11479.357435616439 |
| Lambda | 24099.861013755708 |
| RDS | 16495.44568415525 |
Worked solution and explanation
What this really is
Underneath the cost-accounting story, this is a join on a key that does not exist yet. An allocation's period reads '2026-02' while a charge's bill_date reads '2026-02-01', so a straight equality between them matches nothing: you have to manufacture the month from the date before the join can fire at all. After that it is a plain sum of a per-row product, kept only for the services whose reconciled total clears a materiality bar. The schema also hands you batch_jobs, which shares no meaningful key with either cost table: joining it buys nothing, and if its key fanned out it would silently multiply every total. Get the month wrong and you get zero rows; drag in the wrong table and you cloud a clean aggregate.
Break down the requirements
Step 1: Derive the month, then join on service
The join key is a month that is not stored as a month. cost_allocs.period is year-month ('2026-02') while cloud_costs.bill_date is a full date ('2026-02-01'). Derive the month with strftime('%Y-%m', cc.bill_date) and match it to ca.period, together with svc_name. Leave region out of the key: the two tables disagree on it for the same service and month. A raw bill_date = period equality returns zero rows, the most common way this problem dies.
Step 2: Recognize the distractor table
The schema also offers batch_jobs, but job_id has no relationship to an allocation or a charge, and none of its columns feed the reconciliation. Joining it adds nothing, and if job_id were non-unique the extra rows would multiply into the sum and inflate every total. The disciplined move is to leave it out entirely.
Step 3: Prorate each pairing
Multiply the allocated amount by the actual cloud cost and divide by the hours in a year (8760 = 365 * 24): ca.amount * cc.amount / 8760.0. Write 8760.0 so the division is floating point on an engine that would otherwise truncate integers.
Step 4: Aggregate, then filter on the reconciled total
Sum per service with GROUP BY ca.svc_name, then keep only services whose total tops 10,000 with HAVING SUM(...) > 10000. The threshold bites on the seed: the larger services clear it and the smaller ones fall away, so dropping the HAVING returns extra services and grades wrong.
The solution
SELECT
ca.svc_name AS svc_name,
SUM(ca.amount * cc.amount / 8760.0) AS prorated_cost
FROM cost_allocs ca
INNER JOIN cloud_costs cc
ON ca.svc_name = cc.svc_name
AND ca.period = strftime('%Y-%m', cc.bill_date)
GROUP BY ca.svc_name
HAVING SUM(ca.amount * cc.amount / 8760.0) > 10000;Common follow-up questions
- Suppose a stakeholder insists on attaching `batch_jobs` context and `job_id` turns out to be non-unique. How would that join change your `prorated_cost` totals, and how would you defend the aggregate? (Tests reasoning about join fan-out at a different grain.)
- Why does the materiality filter on the reconciled total belong in `HAVING` rather than `WHERE` here? (Tests pre-aggregation versus post-aggregation filtering.)
- The join matches `ca.period` against a month derived from `bill_date`. What would you change so the optimizer can use an index for that predicate at 15 million rows? (Tests indexing for a join key computed from an expression.)