Why Dropping the View Didn't Fix My Bad dbt Data
The views I express here are mine alone and do not necessarily reflect the opinions or official position of my employer.
A bad upload landed in one of our source tables last month, and it flowed straight through an incremental model into production. My first instinct — drop the downstream view, problem solved, right? — was completely wrong, and I want to walk through why, because I don't think I was the only one who's ever tried that.
Building the incremental model, quickly
Instead of rebuilding an entire table from scratch on every run, an incremental model only processes new or changed rows and appends or merges them into an existing table. That's the whole appeal: faster, cheaper, and it scales. Here's the shape of the one that bit me:
-- models/my_incremental_model.sql
{{ config(
materialized='incremental',
unique_key='id', -- needed for merge/upsert
incremental_strategy='merge' -- recommended for Snowflake (other options exist)
) }}
SELECT
id,
event_timestamp,
value
FROM {{ ref('my_source_table') }}
{% if is_incremental() %}
-- Only pick up rows newer than what's already in the table
WHERE event_timestamp > (SELECT MAX(event_timestamp) FROM {{ this }})
{% endif %}
The concepts that matter here, if you're newer to incremental models than I was when I first shipped one:
| Concept | What it means |
|---|---|
materialized='incremental' | Tells dbt to build a persistent table, not a view |
is_incremental() | A dbt macro — true on subsequent runs, false on first run or full-refresh |
unique_key | The column(s) dbt uses to match and update existing rows |
{{ this }} | Refers to the existing table already sitting in Snowflake |
--full-refresh | Forces dbt to drop and rebuild the table from scratch |
And the strategy choice matters more than I gave it credit for early on:
| Strategy | Best for |
|---|---|
merge | Upserts — update existing rows, insert new ones |
append | Append-only data (logs, events) — no deduplication |
delete+insert | Partition-level replacement |
Why dropping the view didn't touch the bad data
Here's the misconception that got me: an incremental model in dbt is materialized as a physical Snowflake table. A view sitting on top of it is just a saved query — it holds no data of its own.
Source Data → dbt incremental model → Snowflake TABLE → (optional) view on top
So when the bad upload ran:
Run 1 (bad data): [existing rows] + [bad rows] → written into the TABLE
I drop the view: view is gone, but the TABLE still has the bad rows
Dropping the view only removes the lens I was looking through. The bad data was still physically sitting in the underlying table, completely untouched, because dbt's incremental logic doesn't go back and second-guess what it already wrote. It only looks forward — via that WHERE event_timestamp > MAX(...) filter — and it has no concept of "actually, that batch was wrong." As far as dbt was concerned, those rows were already handled.
The actual fix depends on how bad the damage is:
| Fix | Command | When to use |
|---|---|---|
| Full refresh | dbt run --full-refresh --select my_model | Rebuilds the table from scratch — the safest option |
| Manual delete | DELETE FROM my_table WHERE <bad_data_condition> | If you only need specific rows gone |
| Backfill with corrected source | Fix the source, then full-refresh | The best practice, if you can afford the rebuild |
I went with a full refresh once I'd confirmed the source was clean again — slower, but I didn't trust myself to write a DELETE condition precise enough at 11pm.
The part that surprised me: it does self-correct — just not immediately
If the source data gets corrected upstream (the bad upload gets overwritten or removed) and the corrected rows share the same unique_key as the bad ones, a merge strategy will overwrite them automatically on the next run. That part is genuinely reassuring once you know it. What isn't obvious is when "next run" happens.
Airflow DAG schedule: [Run at 02:00] ---------------- [Run at 02:00 next day]
↑
Bad data sits here, uncorrected,
until the next scheduled run
Airflow triggers dbt on a schedule, not continuously. Between runs the Snowflake table is static — it only ever reflects the state of the last successful run. That gap is where the panic sets in if you're staring at a stakeholder dashboard showing wrong numbers and the fix is scheduled for 2am tomorrow.
If you can't wait for the schedule, you've got three ways to force it:
# Option 1: Trigger the DAG manually in the Airflow UI
# Option 2: Run dbt directly with a full refresh
dbt run --full-refresh --select my_model
# Option 3: Run just the incremental pass, if the source is already corrected
dbt run --select my_model
What I'd tell past-me
The lesson wasn't really about dbt syntax — it was about where data actually lives. A view is a window, not a room. If something's wrong in the room, closing the curtains doesn't clean it up. Now, before I touch anything downstream, I ask myself one question first: is this materialized, or is it just a query wearing a table's name? That one distinction would've saved me twenty minutes of being confused and stuck when this happened.