Rewind by
December 1st
A system design story in five tracks. You're the team shipping a personalized year in music to every listener, all on the same morning.
An example design of Spotify Wrapped, inspired by the Spotify Engineering blog.
→ or Space next · N speaker notes
The brief from product, in one sentence
"Give every listener a story about their year."
play events behind one feature
It ships on one morning, and it has to be right, because people will screenshot it.
How would you build it?
Tracklist
Five problems, in the order they bite
Track 01 Just run a query?
It works for one listener.
SELECT artist_id, COUNT(*) AS plays FROM plays WHERE user_id = 'u_4821' AND played_at >= '2026-01-01' GROUP BY artist_id ORDER BY plays DESC LIMIT 5;
-- the extra step: rank fans inside each artist PERCENT_RANK() OVER ( PARTITION BY artist_id ORDER BY plays DESC)
- Run it 500M times and you scan the same 5.5T rows over and over, on the database that serves the app.
- So it becomes a batch job. A scheduler such as Airflow starts it each night, and many workers share the stored play events, split by listener.
- Then product wants "You're in the top 0.5% of this artist's fans."
- Ranking fans compares listeners with each other, so the data has to be split again, by artist.
What happens to the worker that gets the biggest artist?
Track 01 The 9-hour straggler
One artist, one worker, everyone waits.
Guess the job time before I press run. Then tell me how you'd fix it.
Salting: add a suffix, artist_id#0..7, so the hot key spreads over 8 workers. Each works out a partial answer, then one small step merges the 8.
Track 02 Game round · hands up, then flip
Does this count as a play?
None of these is a compute problem. They're definitions, and the definition is the hardest code to change later.
Track 02 Rules as a pipeline
Every event passes three gates.
Which two gates could you safely swap? Which swap breaks the count?
Track 02 The event is a contract
Old app versions send v1 events for years.
play_event v1 (2024)
{
"event_id": "e_7f3a…",
"user_id": "u_4821",
"track_id": "t_9912",
"ts": 1764531900,
"ms_played": 192000
}play_event v2 (2026)
{
"schema": 2,
"event_id": "e_c01d…",
"user_id": "u_4821",
"track_id": "t_9912",
"ts": 1793112000,
"ms_played": 192000,
"content_type": "podcast",
"offline_synced_at": 1794900000
}- Add fields as optional, with a default the reader knows (missing
content_typemeans music). - Never rename a field or change what it means. Add a new one.
- Readers ignore fields they don't know, so an old job keeps working when a new field appears.
Track 03 Code you can trust
November 20th. Podcast minutes are double.
The query, unchanged since March
SELECT p.user_id, SUM(p.ms_played) / 60000 AS podcast_min FROM plays p JOIN episodes e ON e.episode_id = p.episode_id GROUP BY p.user_id;
Nobody touched the query. Why is it wrong now?
| episodes.episode_id | language | added |
|---|---|---|
| ep_301 | en | March |
| ep_301 | es | Nov 18 |
One play of ep_301 now matches two rows, so its minutes are added twice.
Track 03 Before a listener does
Three cheap checks. Which would have caught it?
A · Test the query
plays: ep_301, 30 min episodes: ep_301 expect podcast_min == 30
A unit test with hand-written rows.
B · Check the table first
SELECT episode_id FROM episodes GROUP BY episode_id HAVING COUNT(*) > 1
Any row back: stop the job.
C · Check the result after
podcast_min + music_min
== total_min
-- total comes from raw plays
The parts must add up to the whole.
Our code never changed. The data did. So test what you assume about the data, not only the code.
Which table do you join to without ever checking it?
Track 03 Re-run, don't repair
Fix the code, re-run the year, compare.
1 · Fix
Join to a table with one row per episode.
And keep check B, so the job stops if this ever happens again.
2 · Re-run
Same raw play log, same cutoff date. A new output next to the old one.
rewind_v1 stays. rewind_v2 appears. Nothing is overwritten.
3 · Compare
| u_4821 | v1 | v2 |
|---|---|---|
| podcast min | 1,840 | 920 |
| music min | 12,400 | 12,400 |
| top artist | same | same |
What in that comparison would make you refuse to switch?
Track 04 A pile of cards
Every card is a small plugin with one rule.
type CardGenerator interface { // does this listener get the card? IsEligible(s YearSummary) bool // what does it say? Build(s YearSummary) Card }
- A new card is a new type with these two methods, not an edit to one giant function.
- Each card decides for itself which listeners get it.
Track 04 Who gets which card?
Guess the cards, then pick a listener.
Track 04 Compute when?
At 9:00 AM, or the night before?
On demand · work the servers do
Everyone taps at once, and each tap builds a Rewind from scratch.
Precompute · work the servers do
The cards are already built. Each tap is one lookup.
| key | stored value |
|---|---|
| u_4821 | top_artist, minutes, fan_rank |
| u_0007 | welcome |
Store each listener's finished cards under their user ID. The price: you also store cards for people who never open them.
When would on demand be the right call?
Track 05 Delete my data
A listener asks us to forget them.
Before I press it: where else does this listener's data live?
Recap
One play, all the way to a card.
The real thing Spotify's 2025 Wrapped Archive, simplified
What Spotify built last year
One Wrapped feature: short written reports about each listener's most remarkable days of the year.
- Everything is built ahead of time: "Wrapped doesn't ramp, it spikes!" Track 04
- One database row per listener, found by user ID. Track 04
- A timezone bug gave wrong results. They fixed the pipeline, deleted the bad reports and ran them again. Track 03
Simplified from Inside the Archive: The Tech Behind Your 2025 Wrapped Highlights, Spotify Engineering, March 2026.
Lessons learned
What to take back to your own systems
Find the giant key. When work is split by a key, the one key far bigger than the rest decides how long everything takes.
Definitions are code. Write business rules down in one place, give them a version, and test them.
Test the data, not only the code. And keep raw inputs, so a bug becomes a re-run and a comparison.
Small pieces, built early. Independent parts turn edge cases into test cases. Work done the night before turns a launch into lookups.
Know where your data goes. Every copy is one you must be able to find again, and every rule that helps today has a price later.
Thank you for
your attention
References
- Spotify Unwrapped: How we brought you a decade of dataSpotify Engineering, 2020. One stored result per listener, one re-runnable job per story.
- How Spotify Optimized the Largest Dataflow Job Ever for Wrapped 2020Spotify Engineering, 2021. Huge joins, and what skewed keys do to them.
- Spotify Unwrapped 2019: How We Built an In-App Experience Just for YouSpotify Engineering, 2020. The app side of the story.
- Inside the Archive: The Tech Behind Your 2025 Wrapped HighlightsSpotify Engineering, 2026. The most recent post on Wrapped.
- Designing Data-Intensive ApplicationsMartin Kleppmann. The chapters on batch processing and on changing data formats over time.