Back to blog

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:

ConceptWhat 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_keyThe column(s) dbt uses to match and update existing rows
{{ this }}Refers to the existing table already sitting in Snowflake
--full-refreshForces dbt to drop and rebuild the table from scratch

And the strategy choice matters more than I gave it credit for early on:

StrategyBest for
mergeUpserts — update existing rows, insert new ones
appendAppend-only data (logs, events) — no deduplication
delete+insertPartition-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:

FixCommandWhen to use
Full refreshdbt run --full-refresh --select my_modelRebuilds the table from scratch — the safest option
Manual deleteDELETE FROM my_table WHERE <bad_data_condition>If you only need specific rows gone
Backfill with corrected sourceFix the source, then full-refreshThe 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.