An artist report for a streaming app
One query that builds the monthly report a streaming app sends its artists: plays, listeners, skip rate and their most played song.
What to look at
The query is built in steps with WITH: each step is a small named result that the next one can use, like variables in a program. song_plays counts per song, ranked numbers each artist's songs from most to least played, and totals adds up per artist. The last SELECT joins them onto artists with LEFT JOIN, so an artist with no plays would still appear, with NULLs that COALESCE turns into 0. Watch the 100.0: it makes the skip rate a decimal instead of a whole-number division.
Run it: September for every artist
Run it, read it, change it. Open in playground keeps your own copy.
Tables artists, songs, listeners, plays
| id | name | country | genre |
|---|---|---|---|
| 1 | Nova Lane | UK | pop |
| 2 | The Tidewater | US | indie |
| 3 | Kaito Mori | JP | electronic |
| 4 | Amara Osei | GH | afrobeats |
| 5 | Lumen Choir | SE | pop |
| 6 | Rust & Velvet | UK | rock |
| 7 | Pixel Hearts | KR | pop |
| 8 | Juno Bay | AU | indie |
| id | title | artist_id | seconds | year |
|---|---|---|---|---|
| 1 | Neon Rain | 6 | 178 | 2022 |
| 2 | Slow Motion | 1 | 158 | 2025 |
| 3 | Paper Planes | 2 | 233 | 2023 |
| 4 | Static | 1 | 269 | 2020 |
| 5 | Golden Hour | 1 | 162 | 2022 |
| 6 | Night Bus | 7 | 157 | 2020 |
| 7 | Echoes | 2 | 281 | 2022 |
| 8 | Sugar Rush | 1 | 284 | 2019 |
| 9 | Low Tide | 4 | 289 | 2019 |
| 10 | Glass House | 7 | 152 | 2020 |
| 11 | Satellite | 1 | 282 | 2025 |
| 12 | Wildfire | 3 | 214 | 2022 |
First 12 of 30 rows. Query the table to see the rest.
| id | username | age | country |
|---|---|---|---|
| 1 | maya_k | 15 | UK |
| 2 | devraj | 17 | IN |
| 3 | lucia.p | 16 | ES |
| 4 | tom_h | 14 | UK |
| 5 | ana.s | 18 | BR |
| 6 | kenji | 16 | JP |
| 7 | zoe_r | 15 | US |
| 8 | femi | 17 | NG |
| 9 | liv | 13 | SE |
| 10 | oscar.b | 16 | NULL |
| id | listener_id | song_id | day | skipped |
|---|---|---|---|---|
| 1 | 10 | 16 | 2026-09-01 | 0 |
| 2 | 8 | 3 | 2026-09-01 | 0 |
| 3 | 5 | 16 | 2026-09-01 | 0 |
| 4 | 2 | 2 | 2026-09-01 | 0 |
| 5 | 5 | 21 | 2026-09-01 | 0 |
| 6 | 5 | 23 | 2026-09-02 | 0 |
| 7 | 6 | 1 | 2026-09-02 | 0 |
| 8 | 6 | 6 | 2026-09-02 | 0 |
| 9 | 8 | 2 | 2026-09-02 | 1 |
| 10 | 5 | 5 | 2026-09-02 | 0 |
| 11 | 7 | 13 | 2026-09-02 | 0 |
| 12 | 2 | 6 | 2026-09-03 | 0 |
First 12 of 70 rows. Query the table to see the rest.
-- Per song: how often it was played and skipped
WITH song_plays AS (
SELECT songs.id,
songs.artist_id,
songs.title,
COUNT(*) AS plays,
SUM(plays.skipped) AS skips
FROM songs
JOIN plays ON plays.song_id = songs.id
GROUP BY songs.id
),
-- Number each artist's songs, most played first
ranked AS (
SELECT artist_id,
title,
plays,
ROW_NUMBER() OVER (PARTITION BY artist_id ORDER BY plays DESC, title) AS place
FROM song_plays
),
-- Per artist: plays, different listeners, skips
totals AS (
SELECT songs.artist_id,
COUNT(*) AS plays,
COUNT(DISTINCT plays.listener_id) AS listeners,
SUM(plays.skipped) AS skips
FROM songs
JOIN plays ON plays.song_id = songs.id
GROUP BY songs.artist_id
)
SELECT a.name AS artist,
a.genre,
COALESCE(t.plays, 0) AS plays,
COALESCE(t.listeners, 0) AS listeners,
CAST(ROUND(100.0 * t.skips / t.plays) AS INTEGER) || '%' AS skip_rate,
r.title || ' (' || r.plays || ')' AS top_song
FROM artists AS a
LEFT JOIN totals AS t ON t.artist_id = a.id
LEFT JOIN ranked AS r ON r.artist_id = a.id AND r.place = 1
ORDER BY plays DESC, artist;
Change it
- Add a column
avg_lengthwith the average length of each artist's songs in minutes, rounded to one decimal place. - Only count plays from listeners aged 16 or over. Which step needs the change, and do you need another join?
- Show each artist's two most played songs instead of one. What changes in the last
SELECT, and what happens to the row count? - Add a
WHEREto the lastSELECTso only artists with a skip rate under 30% are listed. Why can you not use the aliasskip_ratethere?