All-Time Spotify Wrapped is a containerized batch analytics pipeline that turns my Spotify listening activity into a year-round, queryable version of Spotify Wrapped. It combines scheduled Spotify Web API ingestion with a multi-year extended streaming history export, models the data into a star schema with dbt on DuckDB, validates it with ~70 dbt tests, and emits column-level lineage for every model to Marquez.
Reference: Builds on the ingestion approach from spotify-api by calbergs, extended with a DuckDB warehouse, dimensional models in dbt, a dbt test suite, and automated column-level lineage with OpenLineage and Marquez.
Spotify Wrapped is released once a year in a fixed format, and the Spotify Web API only returns the 50 most recently played tracks, so any listening history that isn't captured in time is lost. The pipeline is designed to:
dbt_utils for surrogate keysEnd-to-end view of the pipeline orchestrated by Apache Airflow:
API loads into DuckDB raw tables are idempotent: plays are inserted with ON CONFLICT DO NOTHING, and artists, playlists, and playlist tracks are upserted, so reruns don't duplicate API data. File paths are validated before they are used in SQL to prevent injection.
Each run validates the environment, extracts from the API, loads API data, loads extended history, installs dbt dependencies, and runs dbt-ol run, followed by dbt test and dbt docs generate in parallel. All DuckDB tasks share a single-slot Airflow pool and the DAG is limited to one active run, so only one process writes to the database at a time.
fct_listening_history with dim_tracks, dim_artists, dim_albums, and dim_playlistsA surrogate play_id generated from track_id and played_at keeps API and export records from colliding. Audio-feature models and columns were removed after Spotify deprecated that part of its API.
64 schema tests (unique, not null, relationships) plus 6 custom tests covering duplicate plays, valid durations, positive play counts, sequential sessions, valid extended-history IDs, and a reconciliation test that checks fact-table play counts against dimension totals. Source freshness thresholds are configured (6-hour warning and 24-hour error on API plays), but they are not yet enforced in the DAG.
Running dbt through dbt-ol run emits OpenLineage events for every model, and Marquez shows column-level lineage from raw sources through staging and marts to the analytics tables.
A retry callback posts to Slack on every retry attempt, and a failure callback fires once retries are exhausted, including links to the failed tasks and the error message.
dbt-ol run failed with an error reporting that DuckDB was unsupported, so a custom Python script was used in the interim to emit lineage events manually.openlineage-dbt package.openlineage-dbt==1.26.0; dbt-ol run now emits lineage for all 15 models automatically, and the custom script was retired.gosu entrypoint.fct_listening_history (Mar 2022 – Sep 2026): 29.7K+ from the historical export and 3.8K+ captured through 8 scheduled runs a dayAll-time and yearly listening trends by track, artist, and playlist:
dbt source freshness to the DAG so the configured thresholds are enforcedAt production scale, I would move storage to a warehouse or lakehouse with concurrent access, switch full table rebuilds to incremental dbt models, run dbt tests in CI on every pull request, and manage secrets in a dedicated secret manager.
Spotify API usage complies with Spotify's Developer Terms.