Average Compensation by Department and Status
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
- L3
Problem
Finance is comparing how much value different kinds of accounts bring in. For each account status, find the average total value per user, where a user's value is everything they spent on purchases plus the ad revenue their impressions earned. Users who never purchased or never earned ad revenue still count, contributing zero from whichever side they lack.
Average compensation. Department by department.
Tables
users
Columns: user_id INTEGER, username TEXT, email TEXT, signup_date TEXT, account_status TEXT, age_bucket TEXT
| user_id | username | signup_date | account_status | age_bucket | |
|---|---|---|---|---|---|
| 100 | alice | alice@example.com | 2025-02-02 | inactive | 25-34 |
| 197 | aaron42 | aaron42@example.com | 2026-03-03 | suspended | 35-44 |
| 294 | amelia | amelia@example.com | 2024-04-04 | pending_verification | 45-54 |
| 391 | arjun | arjun@example.com | 2025-05-05 | active | 55-64 |
| 488 | ava99 | ava99@example.com | 2026-06-06 | inactive | 65+ |
| 585 | andrew_k | andrew_k@example.com | 2024-07-07 | suspended | NULL |
| 682 | anika | anika@example.com | 2025-08-08 16:34:00 | pending_verification | 18-24 |
| 779 | aiden | aiden@example.com | 2026-09-09 14:53:00 | active | 25-34 |
| 876 | aria_b | aria_b@example.com | 2024-10-10 | inactive | 35-44 |
| 973 | brian | brian@example.com | 2025-11-11 | suspended | 45-54 |
cost_allocs
Columns: alloc_id INTEGER, team_name TEXT, svc_name TEXT, category TEXT, amount REAL, period TEXT, region TEXT, acct_id 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 |
transactions
Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT
| transaction_id | user_id | product_id | quantity | total_amount | transaction_date |
|---|---|---|---|---|---|
| 1067 | 197 | 1001 | 2 | 23.46 | 2026-02-02 |
| 1134 | 294 | 1050 | 3 | 36.93 | 2026-03-03 |
| 1201 | 391 | 1099 | 4 | 50.4 | 2026-04-04 |
| 1268 | 488 | 1148 | 5 | 63.87 | 2026-05-05 |
| 1335 | 585 | 1197 | 0 | 0 | 2026-06-06 |
| 1402 | 682 | 1246 | 2 | 90.81 | 2026-07-07 |
| 1469 | 779 | 1295 | 3 | 104.28 | 2026-08-08 |
| 1536 | 876 | 1344 | 4 | 117.75 | 2026-09-09 |
| 1603 | 973 | 1393 | 5 | -45 | 2026-01-10 |
| 1670 | 1070 | 1442 | 1 | 144.69 | 2026-02-11 |
ad_impressions
Columns: impression_id INTEGER, user_id INTEGER, ad_campaign TEXT, impression_time TEXT, clicked INTEGER, revenue REAL
| impression_id | user_id | ad_campaign | impression_time | clicked | revenue |
|---|---|---|---|---|---|
| 253 | 100 | BRAND_AWARENESS_Q1 | 2026-02-02 01:07:00 | 0 | NULL |
| 306 | 197 | PRODUCT_LAUNCH_X | 2026-03-03 02:14:00 | 0 | NULL |
| 359 | 294 | RETARGETING_CART | 2026-04-04 03:21:00 | 0 | NULL |
| 412 | 391 | HOLIDAY_PROMO | 2026-05-05 04:28:00 | 0 | NULL |
| 465 | 488 | NEW_USER_ACQU | 2026-06-06 05:35:00 | 1 | 0.2 |
| 518 | NULL | LOYALTY_PROGRAM | 2026-07-07 06:42:00 | 0 | NULL |
| 571 | 682 | FLASH_SALE_48H | 2026-08-08 07:49:00 | 0 | NULL |
| 624 | 779 | SUMMER_SALE_2024 | 2026-09-09 08:56:00 | 0 | NULL |
| 677 | 876 | BRAND_AWARENESS_Q1 | 2026-01-10 09:03:00 | 0 | NULL |
| 730 | 973 | PRODUCT_LAUNCH_X | 2026-02-11 10:10:00 | 1 | 0.35 |
Expected output
| account_status | avg_total_value |
|---|---|
| active | 543.0738 |
| inactive | 726.4004000000001 |
| pending_verification | 755.4372 |
| suspended | 736.0114 |
Worked solution and explanation
What this really is
Underneath the finance framing this is a per-user rollup of two sparse fact tables, stitched back onto the full user list and made null-safe before anything is averaged. Each user's value is their spend in transactions plus their earnings in ad_impressions, and the whole difficulty is that most users are missing one side or the other: only a handful ever transacted, and most impressions carry no revenue at all. Inner-join the facts and you quietly drop every user who never bought or never earned ad revenue. Add a raw NULL sum and that user's total collapses to NULL and vanishes from the AVG. Either slip averages only your most active users, so every account status looks far richer than it is.
- users: user_id INTEGER PK, account_status TEXT
- transactions: transaction_id INTEGER PK, user_id INTEGER FK, total_amount REAL
- ad_impressions: impression_id INTEGER PK, user_id INTEGER FK, revenue REAL
- transactions references users
- ad_impressions references users
The trap
- Inner join, raw sums
- Inner-join the two fact sides and add them directly. A user with no ad revenue never appears, and a user with an impression but
NULLrevenue nulls their whole total. Both silently leave the average. - Outer join, coalesce to 0
- Keep every user by anchoring on
users, take spend as a per-user sum and revenue through aLEFT JOIN, thenCOALESCE(..., 0)each side. A missing side contributes 0, the user stays in, and theAVGruns over the true population.
Building it
Step 1: Total each user's spend as a per-user sum
Compute each user's spend with a scalar sum over transactions scoped to that user: (SELECT SUM(t.total_amount) FROM transactions t WHERE t.user_id = u.user_id). The WHERE t.user_id = u.user_id correlates the sum to the one user the outer row is about, so a user with several purchases gets a single correct total with no fan-out, and a user with none returns NULL rather than disappearing.
Step 2: Attach ad revenue without dropping anyone
Pre-aggregate ad_impressions to one row per user (SUM(revenue) ... GROUP BY user_id) and LEFT JOIN that rollup onto the user set. The LEFT JOIN is load-bearing: an inner join would drop every user with no ad revenue, which is most of them, and pull each status average upward.
Step 3: Neutralize the nulls, then average by status
COALESCE(..., 0) each side before adding, so a missing spend or a missing revenue contributes 0 instead of nulling the whole total and evicting the user from the AVG. Anchor the query on users for the full population and its account_status, then group by status and average the per-user totals.
SELECT u2.account_status,
AVG(
COALESCE((SELECT SUM(t.total_amount)
FROM transactions t
WHERE t.user_id = u.user_id), 0)
+ COALESCE(a.total_rev, 0)
) AS avg_total_value
FROM users u
JOIN users u2 ON u2.user_id = u.user_id
LEFT JOIN (
SELECT user_id, SUM(revenue) AS total_rev
FROM ad_impressions
GROUP BY user_id
) a ON a.user_id = u.user_id
GROUP BY u2.account_statusPer-user spend as a correlated sum, ad revenue via an outer-joined rollup, both coalesced, then averaged by status.
Common follow-up questions
- How would the query change if you wanted the average only over users who both purchased and earned ad revenue? (Tests whether they see the outer join and the coalesce as a deliberate choice, reversible to an inner join.)
- A user has 500 impressions but only 2 carry `revenue`. Does your per-user total still compute correctly? (Probes the per-user rollup grain and the NULL-in-SUM behavior.)
- How would you extend this to break the average out by `age_bucket` as well as `account_status`? (Adds a second grouping dimension on top of the same rollup.)