Spotify

Comprehensive database schema for a Spotify-like music streaming platform covering artist profiles, album releases, track catalog with audio features, user-curated and editorial playlists, social following, listening history, liked songs, podcast integration, device management, and listening queue.

PostgreSQL18 tablesWeb App
musicstreamingplaylistpodcastsocial
Created with ERD Studio

About this schema

A track does not belong to an album here. tracks stands on its own with a primary_artist_id, and album_tracks connects the two with a disc number and a position. That is what lets the same recording appear on a single, on the album and on a compilation without being stored three times.

track_features is the table that makes recommendation possible: tempo, energy, danceability, valence and the rest, one row per track. It is separate from tracks because it is written once by an analysis job and read by a recommender, and has no business sitting on the row every play touches.

Follows are two tables — follows_artists and follows_playlists — rather than one polymorphic table. They point at different things, are read by different screens, and neither needs to know about the other, so a shared table would buy a type column and nothing else.

Key relationships

tracks ↔ albums via album_tracks
disc_number and position live on the junction because they describe the track's place on that album, not the track. The same track can sit at position 3 on one release and 11 on another.
tracks → track_features
One row each, written by analysis. Kept apart so the catalogue row stays narrow and the feature vector can be recomputed without touching it.
artists ↔ users via artist_members
An artist profile is managed by accounts, with a role. A band is several people with access to one profile, and a label manager is another.
playlists → playlist_tracks
added_by is on the entry, which is what makes is_collaborative meaningful — you can see who put a track in a shared playlist.
users → listening_history
context_type and context_id record where a play started — an album, a playlist, a radio. Without them, a play is a number; with them, it is evidence about what people actually listen through.

Design decisions

Tracks are not children of albums

Making album_id a column on the track is simpler and wrong as soon as a song appears on a single and then on the album, or on a compilation years later. Each appearance would be a duplicate row with its own play count and its own likes. A junction with disc and position keeps one recording with one popularity, and the album is one of the places it appears.

Audio features are their own table

Eleven numeric columns that no playback path reads do not belong on the row that every play, every search and every listing touches. Separating them also means the analysis job can rewrite a feature vector without competing with playback for the same row.

Listening history records the context

A play with no context tells you a track was heard. A play that knows it started from a playlist tells you the playlist works. context_type and context_id are two columns that turn a log into something a product decision can rest on, and they are cheap because they are not foreign keys — the context may be a radio station that has no table.

Likes and playlists are different things

liked_songs is not a playlist row. It has no position and no owner other than the user, it cannot be shared, and it is read as a set rather than a sequence. Modelling it as a playlist would mean every playlist query carries a special case for the one that is not really a playlist.

Tables in this template

usersUsers
9 cols
artistsArtists
9 cols
artist_membersArtist Members
5 cols
genresGenres
4 cols
albumsAlbums
10 cols
tracksTracks
11 cols
album_tracksAlbum Tracks
6 cols
track_genresTrack Genres
4 cols
track_featuresTrack Features
12 cols
playlistsPlaylists
11 cols
playlist_tracksPlaylist Tracks
6 cols
liked_songsLiked Songs
4 cols
listening_historyListening History
7 cols
follows_artistsFollows Artists
4 cols
follows_playlistsFollows Playlists
4 cols
podcastsPodcasts
9 cols
podcast_episodesPodcast Episodes
9 cols
devicesDevices
8 cols

Frequently asked

Why is there no album_id on tracks?
Because a recording can appear on several releases — a single, the album, a compilation, a deluxe edition. A column forces a duplicate row per appearance, and then play counts and likes split across copies of the same song.
Why are audio features separate from tracks?
They are written once by an analysis job and read only by recommendation. Keeping eleven columns nobody else needs off the hot catalogue row keeps that row narrow, and lets features be recomputed independently.
How are podcasts handled alongside music?
As their own tables — podcasts and podcast_episodes — rather than as tracks with a type. They share almost nothing with music: no album, no artist credits, no audio features, and a different notion of progress. Sharing a table would be a type column and a lot of nulls.
What is context_type in listening_history for?
It records where a play started — an album, a playlist, a radio station. It is what separates 'this song is popular' from 'this playlist is working', and it is the signal most listening logs forget to capture until they need it.
Should liked_songs just be a playlist?
It looks like one, but it has no order, no owner other than the user, and cannot be shared or collaborative. Folding it in means every playlist query special-cases the one row that is not a real playlist, which is more code than a small table.