N058-H3 Tier 5 · Expert · hard analytics · Streamhub

Return every user whose total conversion revenue exceeds the average total conversion revenue across all converting users on the same plan, with their `id`, plan, country, and total conversion revenue

Part of Multi-CTE Query Architecture in SQL

The problem

Scenario: Streamhub's customer success team wants to identify high-value converters within each plan tier — users whose total conversion revenue stands out compared to others on the same plan.

Task: Write a query to return every user whose total conversion revenue exceeds the average total conversion revenue across all converting users on the same plan, with their id, plan, country, and total conversion revenue.

Assumptions:

  • A converting user has at least one conversion on record.
  • A user's total conversion revenue is the combined amount across all of their conversions.
  • The result covers only users whose total conversion revenue is strictly greater than the average total conversion revenue across all converting users on the same plan.

Output:

  • One row per qualifying user.
  • Columns in this order: user_id, plan, country, total_revenue.
  • Sorted by total_revenue descending.
Schema · analytics 5 tables
users
id integer
name text
email text
country text
plan text
signed_up_at timestamptz
is_active boolean
conversions
id integer
user_id integer
converted_at timestamptz
plan text
amount numeric
sessions
id integer
user_id integer
started_at timestamptz
ended_at? timestamptz
event_count integer
events
id integer
user_id integer
session_id? integer
event_type text
occurred_at timestamptz
properties? jsonb
periods
id integer
name text
start_month integer
end_month integer

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution Try it yourself first
Solution query
WITH
  user_conversions AS (
    SELECT
      u.id AS user_id,
      u.plan,
      u.country,
      c.amount
    FROM
      users u
      JOIN conversions c ON c.user_id = u.id
  ),
  user_totals AS (
    SELECT
      user_id,
      plan,
      country,
      SUM(amount) AS total_revenue
    FROM
      user_conversions
    GROUP BY
      user_id,
      plan,
      country
  ),
  plan_benchmarks AS (
    SELECT
      user_id,
      plan,
      country,
      total_revenue,
      AVG(total_revenue) OVER (
        PARTITION BY
          plan
      ) AS plan_avg_revenue
    FROM
      user_totals
  )
SELECT
  user_id,
  plan,
  country,
  total_revenue
FROM
  plan_benchmarks
WHERE
  total_revenue > plan_avg_revenue
ORDER BY
  total_revenue DESC

The shape

Three CTEs that compute each user's total in one layer, attach a per-plan benchmark via a window function in the next, and keep only the users above their plan's number in the main query. The benchmark is partitioned by plan, so every user gets compared against the average of users on the same plan, not the global average.

Clause by clause

WITH user_conversions AS (
    SELECT u.id AS user_id, u.plan, u.country, c.amount
    FROM users u
    JOIN conversions c ON c.user_id = u.id
)

The inner join attaches each conversion to its user. Users with no conversions never appear because the join drops them, which matches the "converting users" requirement. plan and country are carried forward because both belong in the output and plan is the partition key downstream.

user_totals AS (
    SELECT user_id, plan, country, SUM(amount) AS total_revenue
    FROM user_conversions
    GROUP BY user_id, plan, country
)

GROUP BY user_id, plan, country collapses the conversions to one row per user. SUM(amount) is the user's lifetime conversion revenue. The identifying columns ride along because the comparison layer needs them.

plan_benchmarks AS (
    SELECT user_id, plan, country, total_revenue,
           AVG(total_revenue) OVER (PARTITION BY plan) AS plan_avg_revenue
    FROM user_totals
)

AVG(total_revenue) OVER (PARTITION BY plan) computes the average of total_revenue within each plan and broadcasts that single value onto every row in the partition. Every user row now sits next to its plan's average, without a self-join or scalar subquery.

  • SELECT user_id, plan, country, total_revenue FROM plan_benchmarks WHERE total_revenue > plan_avg_revenue ORDER BY total_revenue DESC keeps only the users whose total exceeds their plan's benchmark. Four pro users at 499 clear that plan's bar, eight starter users at 49 clear theirs.

Why a window function and not a separate aggregate CTE

The benchmark could be written as a separate CTE that groups by plan and produces one average per plan, then joined back to user_totals. The result matches. The window form is tighter for this shape because the comparison is the only thing the benchmark is used for: there is no other layer that consumes the per-plan average. Computing it inline via OVER (PARTITION BY plan) keeps the calculation next to the row it is compared against. When the benchmark itself becomes a deliverable that other layers want to read, the aggregate-and-rejoin shape pays off; here it would just add a layer for no gain.

The trap

The partition key controls which population the comparison measures against. AVG(total_revenue) OVER () with an empty window would compute one global average and compare every user to it — a pro user at 499 would lose to the high pro average while a starter user at 49 would clear the much lower global number, even though the prompt asks for a per-plan comparison. PARTITION BY plan is what makes "compared to others on the same plan" mean exactly that. The clause is small, but it is the difference between answering the question and answering a different one.

You practiced computing each user's total in one CTE, attaching a per-plan window benchmark in another, then keeping only users above their plan's benchmark — three layers, one comparison.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.

Practice, feedback, mastery, review. That's the loop that turns reading into real skill.

Start free

No account, no credit card. Start solving in under a minute.