Module 5: Snapshots And Scd Type 2

Configuring `dim_product_snapshot`

Description

It's time to declare this module's real snapshot. This lesson writes snapshots/dim_product_snapshot.yml, explains every configuration key with the evidence you already built in lessons 2 and 3, and resolves two design decisions you haven't seen yet: where a snapshot's definition lives in dbt-core 1.12.2 (verified against the official documentation, because this point changed between dbt versions), and which table it reads from — the raw source, not stg_products.

Connection to the module. Lessons 2 and 3 gave you the mechanism and the comparison criterion, both with your own SQL, without yet touching dbt's real syntax. This lesson puts the two pieces together in the file lessons 5 and 6 are going to actually run. Nothing gets executed yet in this lesson — the first real dbt snapshot arrives in lesson 5 — but by the end of it, dbt is already going to recognize dim_product_snapshot as a valid project resource.

An analogy: the archivist's contract, in writing

Lesson 1 compared a snapshot to hiring, full-time, the archivist you used to call by the job. This lesson is that archivist's employment contract, in writing: which exact folder they have to watch (relation), what they use as a unique identifier for each file (unique_key), and what exact criterion they have to apply to decide if something changed (strategy + updated_at). An archivist with no such written contract would improvise their own rules — maybe comparing everything, maybe nothing; with the contract, both you and any coworker who reads the project know, unambiguously, exactly which criterion is being followed.

Prior verification: what's the current syntax, not the one that "looks simpler"

Before writing a single line, it's worth resolving a question that changed between dbt versions: where does a snapshot get declared? For years, the only way was a {% snapshot name %} ... {% endsnapshot %} block inside a .sql file, with the configuration inside, in a {{ config(...) }}. Since dbt-core 1.9, dbt Labs's official documentation recommends declaring snapshots in a YAML properties file; the previous form — the {% snapshot %} block with config() inside a .sql — still works, but stopped being the recommended form. The current form, the one dbt-core 1.12.2 (this project's version) expects you to use for new snapshots, is a YAML properties file, with a top-level snapshots: key — the same kind of file you've already written over and over since module 2 (_sources.yml, _models.yml), applied now to a different resource.

This guide uses the current YAML syntax for that concrete reason — not because it "looks more modern," but because it is, verified against the official documentation while writing this module, the non-deprecated form. It's still worth recognizing the legacy form, though, because you're going to find it in older dbt projects you follow or inherit:

-- LEGACY form (still works, no longer recommended since dbt-core 1.9)
{% snapshot dim_product_snapshot %}
{{
    config(
      unique_key='product_id',
      strategy='timestamp',
      updated_at='product_updated_at',
    )
}}
select * from {{ source('kiosko_raw', 'products') }}
{% endsnapshot %}

Compare it, in a moment, against the form this guide actually uses.

Worked example: snapshots/dim_product_snapshot.yml

Create the file, inside the snapshots/ folder dbt_project.yml has already reserved since module 1 (snapshot-paths: ["snapshots"] — you never touched it until now, because this is the whole guide's first snapshot):

# snapshots/dim_product_snapshot.yml
snapshots:
  - name: dim_product_snapshot
    relation: source('kiosko_raw', 'products')
    config:
      unique_key: product_id
      strategy: timestamp
      updated_at: product_updated_at

Five lines, five decisions:

  • snapshots: — the top-level key that tells dbt "what follows is a list of snapshots," the same pattern sources: and models: already used in earlier files.
  • name: dim_product_snapshot — the resource's name. It's also going to be the name of the table that shows up in kiosko.duckdb once you run it — there's no separate "name the output table" step.
  • relation: source('kiosko_raw', 'products') — where the snapshot reads from on every run. Notice the syntax: it's the same source() function you've already used since module 2 inside a .sql, but here, inside YAML, it's written as text — the same pattern you already saw in module 4, when you declared to: ref('dim_store') inside a relationships test. dbt recognizes these calls inside YAML and resolves them exactly as if they were in a .sql.
  • unique_key: product_id — which column identifies "the same product" from one run to the next. Without this, dbt would have no way to know that P002's new row in products_v2.csv is a version of the same product it already had archived, instead of a completely different product.
  • strategy: timestamp / updated_at: product_updated_at — the comparison criterion you already justified with evidence in lesson 3: compare the source's date against the already-archived date, never the system clock.

Why relation points at the source, not at ref('stg_products')

This is the second design decision this lesson resolves, and it's worth a full explanation, because it contradicts the habit you built since module 3: every mart you've written so far — dim_store, dim_date, fact_orders — uses ref() over a staging model, never source() directly. A snapshot breaks that pattern on purpose.

The reason is a guarantee a snapshot needs, and a staging model can't give it: dbt Labs explicitly recommends that a snapshot's query read from the source as raw as possible — the source, or at most a minimal view with no business logic — never a transformed model that could change shape over time. Think of it this way: if stg_products.sql changed in the future — say, if someone added a WHERE category != 'discontinued' to filter out discontinued products — a snapshot depending on stg_products would start "seeing" a different catalog than the real one, and the history it builds would reflect that filtering logic, not Kiosko's true catalog. A snapshot that reads from the raw source, on the other hand, is protected from any future change in the staging layer — its history always reflects the data exactly as it arrived, the most honest possible definition of "what really happened."

There's a second, more practical reason, specific to this module: stg_products.sql casts product_updated_at to date explicitly (cast(product_updated_at as date), since module 2) — but the raw source, read directly by DuckDB, already infers that same type automatically (you confirmed this in module 2, lesson 4). Snapshotting the source loses no type guarantee in this specific case, and avoids a dependency (ref('stg_products')) that, for this specific purpose, adds nothing — the snapshot needs no additional cast(), no cleanup, just the raw data as it arrives.

Confirming dbt recognizes the snapshot, without running it yet

With the file already written, confirm dbt recognizes it as a valid resource — the same kind of check you already did with dbt ls --select source:kiosko_raw in module 2, before any real data existed to read:

dbt ls --select dim_product_snapshot

What to expect.

Running with dbt=1.12.2
Registered adapter: duckdb=1.11.0
Found 7 models, 1 snapshot, 15 data tests, 4 sources, 501 macros
kiosko_analytics.dim_product_snapshot

Found ... 1 snapshot — the count already recognizes it, and kiosko_analytics.dim_product_snapshot is the resource's complete identifier, with the same project-name-dot-resource-name pattern you already saw in dbt ls since module 2. Notice it doesn't say snapshots.dim_product_snapshot or any folder prefix — unlike staging models (kiosko_analytics.staging.kiosko.stg_orders), a snapshot at snapshots/'s root doesn't inherit any subfolder prefix in its full name. None of this has run any real query yet against raw_data/kiosko/products_v1.csv — it's exactly the same distinction module 2 already pointed out between "dbt recognizes it exists" and "dbt has already read real data."

Diagram: what exists by the end of this lesson

kiosko_analytics/
├── dbt_project.yml
├── profiles.yml
├── raw_data/kiosko/          <- products_v1.csv, unchanged since module 2
├── models/
│   ├── staging/kiosko/        <- 4 staging models, untouched
│   └── marts/                 <- 3 marts, untouched
└── snapshots/
    └── dim_product_snapshot.yml   <- NEW, this lesson

kiosko.duckdb                 <- no dim_product_snapshot table yet

dim_product_snapshot.yml exists and dbt recognizes it, but there's still no dim_product_snapshot table inside kiosko.duckdb — that table is only born the first time you run dbt snapshot, in lesson 5.

Common mistakes

Writing relation: ref('stg_products') because "that's how it's done since module 3." What happens: someone, following three modules' habit, writes ref('stg_products') instead of source('kiosko_raw', 'products'). Why it happens: ref() is the mechanism you've used consistently in every mart — switching to source() right here feels like an arbitrary exception. How to spot it: technically, ref('stg_products') would also work — the snapshot would run with no error — so this isn't a mistake dbt shows you with a message; it's a wrong design decision that only becomes noticeable if stg_products changes shape in the future. How to fix it: remember this lesson's explanation — a snapshot reads from the rawest possible source, precisely so it inherits no future change from the staging layer. source(), not ref(), is the right choice for this specific resource.

Putting the configuration inside columns: instead of config:. What happens: someone, used to _models.yml declaring data_tests: inside a columns: list, tries to put unique_key/strategy/updated_at in a similar place, instead of inside the snapshot-level config: key. Why it happens: a snapshot's YAML structure looks, at first glance, like a model's with tests declared — both are nested lists — but the valid keys at each level are different. How to spot it: dbt parse or dbt ls --select dim_product_snapshot fail with a schema validation error if the structure doesn't match what dbt expects. How to fix it: copy this lesson's worked example's exact structure — name, relation, and config are the three top-level keys of each entry under snapshots:; unique_key, strategy, and updated_at always live inside config:.

Using the legacy {% snapshot %} block syntax because "it shows up in more older tutorials." What happens: someone finds, searching online, dbt examples with the {% snapshot name %} {{ config(...) }} ... {% endsnapshot %} form — more common in tutorials and documentation from versions before dbt-core 1.9 — and copies it without checking whether it's still the recommended form. Why it happens: the legacy form still works perfectly in dbt-core 1.12.2 — it gives no error or warning for using it — so there's no immediate signal it stopped being preferred. How to spot it: dbt's own official documentation, on the snapshots page, recommends the YAML form since dbt-core 1.9 and presents the configuration inside a .sql block as the earlier form. How to fix it: for a new project (like this guide's), always use this lesson's YAML syntax — the legacy form only makes sense if you're maintaining an existing project that already uses it, not for new code.

Exercises

Exercise 1 — Rewrite dim_product_snapshot in the legacy form, and compare it. With no change in behavior, write the .sql version with {% snapshot %} of this lesson's configuration. Which information is identical between the two forms, and what only changes in syntax?

See solution
-- snapshots/dim_product_snapshot.sql (legacy form, NOT the one this module uses)
{% snapshot dim_product_snapshot %}
{{
    config(
      unique_key='product_id',
      strategy='timestamp',
      updated_at='product_updated_at',
    )
}}
select * from {{ source('kiosko_raw', 'products') }}
{% endsnapshot %}

The five pieces of information are identical — the resource's name, the source, unique_key, strategy, updated_at — the only thing that changes is where each one lives: in the YAML form, relation replaces the block body's select * from {{ source(...) }}, and config: replaces the call to the config(...) function. The resulting behavior — which table it creates, how it decides if something changed — is exactly the same in both forms; only the syntax you tell dbt with changes.

Exercise 2 — Break unique_key on purpose and read the error. Temporarily change unique_key: product_id to unique_key: product_name in your dim_product_snapshot.yml, and run dbt ls --select dim_product_snapshot (still without dbt snapshot, which arrives in lesson 5). Does it give any error at this point?

See solution

No, dbt ls doesn't detect any problem — product_name is a valid column that exists in products, so syntax validation passes with no complaint. The problem with this choice would only show up when you run the snapshot: if two different products shared the same product_name (doesn't happen in Kiosko's catalog, but would be possible in a real catalog), dbt would treat them as if they were the same product, mixing up their history. This confirms something important: dbt validates that a unique_key is syntactically valid, but it can't validate that it's, semantically, the correct column to uniquely identify each row — that responsibility is yours. Undo the change, back to unique_key: product_id, before continuing to lesson 5.

Exercise 3 — Explain, in your own words, why relation uses a function and not a direct file path. In 2-3 sentences, and using what you already know about source() since module 2, explain why relation: source('kiosko_raw', 'products') is preferable to, say, a direct relation: "raw_data/kiosko/products_v1.csv".

See solution

Using source() keeps the same guarantee module 2 already justified when it introduced that function: if tomorrow the physical file changes location or name — something that's actually going to happen in lesson 6, when external_location changes from products_v1.csv to products_v2.csv — you only need to update _sources.yml, in one single place, without touching the snapshot. If relation pointed directly at a file path, you'd have to edit the snapshot every time the source changed location, duplicating a decision that already lives, correctly, in the source's declaration.

Summary and next step

This lesson declared snapshots/dim_product_snapshot.yml, with the YAML syntax current in dbt-core 1.12.2 — verified against the official documentation, which marks the legacy form ({% snapshot %} + config() in .sql) as not recommended since dbt-core 1.9. You configured unique_key: product_id, strategy: timestamp, updated_at: product_updated_at, pointing with relation: source('kiosko_raw', 'products') at the raw source, not at stg_products — a justified design decision, not a syntax accident. You confirmed with dbt ls that dbt recognizes the snapshot, without having run any real query yet.

Before moving on you should be able to: write from memory a snapshot's five minimum keys in the current YAML syntax; and explain, in your own words, why relation points at the source and not at a staging model.

Lesson 5 runs dbt snapshot for the first time — the whole project's first real history table.

Resources

  • dbt Developer Hub — "Add snapshots to your DAG," configuration section — explicitly confirms that defining a snapshot in .sql with config() is a legacy method since dbt-core 1.9, and shows the current YAML syntax this lesson uses. docs.getdbt.com/docs/build/snapshots. In English.
  • dbt Developer Hub — "Snapshot properties," the reference for the snapshots: key in YAML properties files, including the relation key. docs.getdbt.com/reference/snapshot-properties. In English.
  • dbt Developer Hub — "Snapshot configurations," the complete reference for unique_key, strategy, updated_at, and the rest of config:'s keys. docs.getdbt.com/reference/snapshot-configs. In English.
  • dbt Developer Hub — project structure best practice, marts section — the same principle ("don't depend on a layer that can change shape") that justifies snapshotting the source instead of stg_products. docs.getdbt.com/best-practices/how-we-structure/4-marts. In English.