SQL Problem Solving: Writing Queries

Concept. Most queries you write are one of five shapes: filter to a subset, rank within groups, accumulate over time, compare to a group, or flag outliers. Then you can combine one or more of these patterns, to solve bigger problems.

Intuition. Recognize the shape of sub-parts of the problem. "Top 3 songs per genre" is a ranking; "users who played but never shared" is a set difference. The five patterns below give each shape a skeleton.

Five Query-Writing Patterns

Picture the result rows you want, then pick the clauses that build them. For each pattern, name what one output row represents before writing the query.

Each pattern uses its own small illustrative schema.

  1. The Funnel: find drop-offs (listened, but never shared).

  2. The Comparison: individual vs. group metrics (artist vs. genre average).

  3. The Ladder: rank items within groups (top songs per genre).

  4. The Timeline: track cumulative behavior (running total of listens).

  5. The Exception (optional): spot outliers (viral surges in daily listening).

Pattern 1: The Funnel

Keep users who appear in the listens set and exclude those in the shares set. As in the earlier NOT IN trap, this comparison assumes the shares user IDs contain no NULLs.

The funnel narrows from played 1000 to made-a-playlist 450 to shared 87. Keep one set and subtract another with WHERE user_id IN (listens) AND user_id NOT IN (shares).

Pattern 2: The Comparison

The window computes a genre average without collapsing the artist rows. Subtract that average from each artist’s plays: positive means above average, negative means below.

Three artists' plays against a dashed genre-average line, two above and one below, via plays - AVG(plays) OVER (PARTITION BY genre).

Pattern 3: The Ladder

PARTITION BY restarts numbering for each genre; ORDER BY plays DESC puts the most-played songs first. The outer query then keeps row numbers 1 through 3.

A ladder of Pop songs numbered by plays: ranks 1 to 3 kept on the top rungs, rank 4 cut below the line, via ROW_NUMBER() OVER (PARTITION BY genre ORDER BY plays DESC) with rn at most 3.

Pattern 4: The Timeline

An ordered SUM keeps each daily row and adds the hours up through that date. This example has one row per day: 3, then 3 + 5 = 8, then 10, then 14 cumulative hours.

A running total that accumulates 3, then 8, then 10, then 14 across days, via SUM(hours) OVER (ORDER BY date).

Pattern 5: The Exception (Optional)

LAG(listens) supplies the previous row’s listen count within each song, ordered by date. The first row has no predecessor, so its value is NULL. Compare the current count to that prior count, then filter the percentage change; NULLIF in the query avoids division by zero.

Daily listens whose Thursday row spikes 138% and is flagged, via LAG(listens) to get the previous day then a percent-change > 50 filter.


Example 1: Breakout Artists (Optional)

Problem: identify breakout artists with over 50% month-over-month growth in the last 3 months. Patterns used: Timeline (LAG for the previous month) and Comparison (the percent change).

Step 1: Count monthly plays. Establish a baseline: count plays for each artist per month.

WITH monthly_plays AS (
  SELECT
    artist_id,
    DATE_TRUNC('month', play_date) AS month,   -- round to the month
    COUNT(*) AS play_count
  FROM listens
  GROUP BY artist_id, month                     -- one row per artist-month
)
-- artist_id | month   | play_count
-- 1234      | 2024-09 | 1000        (Sept baseline)
-- 1234      | 2024-10 | 1600        (Oct growth)
-- 1234      | 2024-11 | 2500        (Nov spike)

Step 2: Calculate the growth rate. Compare each month to the previous one with LAG().

, artist_growth AS (
  SELECT
    artist_id, month, play_count,
    LAG(play_count) OVER (
      PARTITION BY artist_id        -- reset per artist
      ORDER BY month                -- in time order
    ) AS prev_month_plays,
    ROUND(
      (play_count - LAG(play_count) OVER (PARTITION BY artist_id ORDER BY month))
      * 100.0 / NULLIF(LAG(play_count) OVER (PARTITION BY artist_id ORDER BY month), 0),
      1
    ) AS growth_pct
  FROM monthly_plays
)
-- artist_id | month   | play_count | prev | growth_pct
-- 1234      | 2024-09 | 1000       | NULL | NULL      (no prior month)
-- 1234      | 2024-10 | 1600       | 1000 | 60.0      (+60%)
-- 1234      | 2024-11 | 2500       | 1600 | 56.3      (+56%)

Step 3: Find the breakout artists. Filter to over 50% growth in the last 3 months.

SELECT a.name AS artist_name, g.month, g.play_count, g.growth_pct
FROM artist_growth g
JOIN artists a ON g.artist_id = a.artist_id
WHERE g.growth_pct > 50                                        -- the breakout filter
  AND g.month >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '3 months'
ORDER BY g.growth_pct DESC, g.month DESC

-- artist_name | month   | play_count | growth_pct
-- Dua Lipa    | 2024-10 | 1600       | 60.0        (+60% on Sept, artist 1234 above)
-- Dua Lipa    | 2024-11 | 2500       | 56.3        (+56% on Oct)

Takeaway

Start with the desired output, then build the intermediate results needed to produce it.