Project
movie-ratings-analytics
A layered dbt project on Snowflake over the MovieLens 20M dataset, with SCD Type 2 snapshots, incremental models, source freshness, a custom generic test, per-pull-request CI and Airflow orchestration at per-model granularity.
The dataset is MovieLens 20M: 20,000,263 ratings, 465,564 tag applications and an 11.7M-row movie–tag relevance matrix, across 27,278 films and 138,493 users. It lands in five raw tables in Snowflake, and dbt models it in layers: staging, then dimensions and facts, then marts.
Data flows from the MovieLens 20M CSV files into five raw tables in Snowflake. dbt builds five staging views over them, then six dimension and fact models, two of them incremental, then two mart tables. A snapshot, snap_movies, reads the raw movies table directly and keeps SCD Type 2 history. Airflow runs the project monthly through Astronomer Cosmos, one task per model.
- MovieLens 20M feeds Snowflake RAW
- Snowflake RAW feeds staging
- Snowflake RAW feeds snap_movies
- staging feeds dims + facts
- dims + facts feeds marts
Every model, column and test is documented in the published dbt docs, with the full lineage graph from raw sources to marts.
Lineage
Sixteen dbt nodes over five sources. Each node’s outline shows its materialisation.
- source
- view
- ephemeral
- table
- incremental
- snapshot
- seed
Full model lineage. Five raw sources each feed one staging view; raw_movies also feeds the snap_movies snapshot. stg_ratings feeds fct_ratings and dim_users; stg_tags also feeds dim_users. stg_movies feeds dim_movies, stg_genome_tags feeds dim_genome_tags, and stg_genome_scores feeds fct_genome_scores. fct_ratings feeds fct_movie_rating_daily. dim_genome_tags, dim_movies and fct_genome_scores feed the ephemeral int_movie_tag_relevance, which with the seed_genre_groups seed feeds mart_tag_genre_affinity. dim_users and fct_ratings feed mart_user_cohort_retention.
- raw_ratings feeds stg_ratings
- raw_tags feeds stg_tags
- raw_movies feeds stg_movies
- raw_movies feeds snap_movies
- raw_genome_tags feeds stg_genome_tags
- raw_genome_scores feeds stg_genome_scores
- stg_ratings feeds fct_ratings
- stg_ratings feeds dim_users
- stg_tags feeds dim_users
- stg_movies feeds dim_movies
- stg_genome_tags feeds dim_genome_tags
- stg_genome_scores feeds fct_genome_scores
- fct_ratings feeds fct_movie_rating_daily
- dim_genome_tags feeds int_movie_tag_relevance
- dim_movies feeds int_movie_tag_relevance
- fct_genome_scores feeds int_movie_tag_relevance
- int_movie_tag_relevance feeds mart_tag_genre_affinity
- seed_genre_groups feeds mart_tag_genre_affinity
- dim_users feeds mart_user_cohort_retention
- fct_ratings feeds mart_user_cohort_retention
Materialisation as a layer policy
Defaults are set per layer in dbt_project.yml rather than per model, so each one is a stated position. A model that overrides its layer explains why in its own file.
| Layer | Default | Why |
|---|---|---|
| staging | view | Rename and cast only. Nothing worth storing. |
| intermediate | ephemeral | Inlined into its single consumer. |
| dim / fct | table | Joined constantly by marts and ad-hoc queries. |
| marts | table | Expensive to compute, small output, read repeatedly. |
Three models override the default:
fct_genome_scoresis a view. It applies aROUND()and nothing else, so a table would store a second copy of 11.7M rows to hold a rounded float. The expensive work in that branch happens inmart_tag_genre_affinity, and that is where the table lives.fct_ratingsis incremental, and volume isn’t the reason. A measured full rebuild of all 20,000,263 rows takes 3.0 seconds on an XS warehouse. It’s incremental because of its shape: an append-mostly event stream, merged on(user_id, movie_id). The boundary filter is>=, not>, because 34.7% of ratings share their second with another rating, and a strict>would silently drop rows tied with the high-water mark.fct_movie_rating_dailyis incremental with a lookback window. A rolling 28-day measure changes for each of the previous 27 days whenever a new rating lands, so a high-water-mark filter would freeze the recent tail at wrong values. The model reprocesses a window and merges, and it reads twice as far back as it writes, because the oldest reprocessed day needs its own 28 days of history.
Checked against the source
Every claim the project makes about the data was checked against the raw CSVs instead of being assumed: zero duplicate (user, movie) pairs, which is what makes the merge safe; zero null ratings; ratings bounded between 0.5 and 5.0; a seed that matches the genre vocabulary exactly.
Two of those checks changed the code. Every one of the 7,801 tagging users has also rated, so a planned is_tag_only_user flag would have been false for all 138,493 users, and it was dropped. And release_year is null for 23 films, a few of them because of malformed titles, so the column’s documentation names the number and the cause.
A filter that removes nothing is a test that never runs.
WHERE rating IS NOT NULL would remove zero of 20,000,263 rows. It looks like validation while asserting nothing, and it would keep looking like validation on the day the property stopped holding. So those properties are tests, and tests can fail.
Snapshots: SCD Type 2 on the source
snap_movies keeps the history of movie metadata, because titles get corrected and genres get reclassified, which is the slow change SCD Type 2 exists for. Tags are immutable events, so snapshotting them would capture nothing.
checkstrategy ontitleandgenres.raw_movieshas noupdated_atcolumn, so the source forces this choice.- It snapshots the source, not
dim_movies. If a snapshot reads a model, refactoring that model rewrites history: switchingINITCAPtoUPPERwould record a change to all 27,278 films. hard_deletes='invalidate'. A deleted row closes its open record instead of staying current forever.
The source is a static 2015 extract, so committed SQL scripts stage reproducible drift (a genre reclassification, a title correction and a hard delete) to give the snapshot something to record.
Tests
106 tests. Three kinds are worth pointing at:
no_overlapping_versions, a custom generic test. dbt maintainsdbt_valid_fromanddbt_valid_tobut never checks that the result is coherent. The test asserts that validity windows tile each entity’s timeline (no overlaps, no gaps, exactly one open record, no window closing before it opens) and reports which of the four failure modes fired.n_ratings_28d >= n_ratings_7d. A relationship between columns, not a range on one: a 28-day window must contain at least as much as the 7-day window inside it. It catches the frame-definition errors the lookback design is most exposed to.- Source-level tests, so key violations fail at the boundary instead of three models downstream.
Freshness
Freshness is measured from warehouse load metadata, not event timestamps. The newest rating in MovieLens is from 2015, so event time would report permanent staleness, and a broken loader would look identical to a healthy one. Thresholds match a monthly load cadence. The genome tables have freshness disabled outright, because MovieLens regenerates them every few years, and an alert nobody can act on trains people to ignore alerts.
CI
GitHub Actions runs dbt build on every pull request against a schema named for that pull request, and drops it afterwards with if: always(), so a failed build still cleans up.
- Source freshness runs but doesn’t block. A stale source is a data problem, not a problem with the code under review.
- The schema-drop macro refuses any target but
ci. It issuesDROP SCHEMA … CASCADE, so a forgotten--targetfails loudly instead of running against prod. - Slim CI is deliberately deferred. At sixteen nodes a full build is cheap enough that state-based selection isn’t worth its complexity yet.
Orchestration
Airflow runs the project monthly, matching the source’s load cadence, through Astronomer Cosmos. Cosmos parses the dbt manifest and emits one Airflow task per model, with each model’s tests running right after it. Retries are per model, the graph view shows real lineage, and a failure names the model that failed. A single dbt build task would retry everything, re-running a 20M-row incremental that had already succeeded.
What changes at 100× volume
fct_ratingsbecomes dependent on incremental loading rather than choosing it, and would need clustering onrating_timestamp, probably withdelete+inserton a partition boundary instead of a two-column merge.dim_usersrecomputes per-user aggregates over the full history on every run, so it would have to become incremental too.mart_user_cohort_retentionfalls over first:COUNT(DISTINCT user_id)across a 2B-row join would move to an approximate count or a pre-aggregated user-month table.int_movie_tag_relevancestays fine, because the genome matrix doesn’t grow with rating volume.
The README covers each decision in more depth, including what I would do differently.