Brainlag
  1. Library
  2. Examples
  3. SQL

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.

SQL15 min

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
artists 8 rows, 4 columns
idnamecountrygenre
1Nova LaneUKpop
2The TidewaterUSindie
3Kaito MoriJPelectronic
4Amara OseiGHafrobeats
5Lumen ChoirSEpop
6Rust & VelvetUKrock
7Pixel HeartsKRpop
8Juno BayAUindie
songs 30 rows, 5 columns
idtitleartist_idsecondsyear
1Neon Rain61782022
2Slow Motion11582025
3Paper Planes22332023
4Static12692020
5Golden Hour11622022
6Night Bus71572020
7Echoes22812022
8Sugar Rush12842019
9Low Tide42892019
10Glass House71522020
11Satellite12822025
12Wildfire32142022

First 12 of 30 rows. Query the table to see the rest.

listeners 10 rows, 4 columns
idusernameagecountry
1maya_k15UK
2devraj17IN
3lucia.p16ES
4tom_h14UK
5ana.s18BR
6kenji16JP
7zoe_r15US
8femi17NG
9liv13SE
10oscar.b16NULL
plays 70 rows, 5 columns
idlistener_idsong_iddayskipped
110162026-09-010
2832026-09-010
35162026-09-010
4222026-09-010
55212026-09-010
65232026-09-020
7612026-09-020
8662026-09-020
9822026-09-021
10552026-09-020
117132026-09-020
12262026-09-030

First 12 of 70 rows. Query the table to see the rest.

query.sql to run
-- 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_length with 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 WHERE to the last SELECT so only artists with a skip rate under 30% are listed. Why can you not use the alias skip_rate there?

Code editor. Press Control or Command plus Enter to run the code. Tab indents; to move focus out of the editor, press Escape and then Tab.