---
title: "dbt incremental models: how unique_key works | Metacenta"
description: "unique_key tells a dbt incremental model which rows to update rather than append. How to set one, use several columns, handle nulls and pick a strategy."
url: "https://metacenta.com/rules/incremental-without-unique-key"
updated: "2026-10-14"
tokens: 1236
---

Modelling correctness · rule incremental-without-unique-key

# dbt incremental models: how unique_key works

`unique_key` tells a dbt incremental model how to recognise a row it has already loaded. With a key, dbt updates the matching row. Without one, most adapters only append, so a row that's read twice is stored twice.

A Metacenta review checks this under the rule **Incremental models declare a unique key**. Everything below applies whether or not you ever commission one.

## How to set unique_key

Add it to the model's config, alongside a filter that limits what each run reads.

```sql
{{ config(materialized='incremental', unique_key='order_id') }}

select order_id, status, updated_at
from {{ ref('stg_shop__orders') }}
{% if is_incremental() %}
where updated_at >= (select coalesce(max(updated_at), '1900-01-01') from {{ this }})
{% endif %}
```

If a key from the new rows already exists, dbt updates or replaces that row. If it doesn't, dbt inserts it. How it does that depends on the warehouse and the strategy.

## How to use more than one column

Pass a list when one column can't identify a row. dbt recommends this over concatenating columns yourself.

```sql
{{ config(materialized='incremental', unique_key=['user_id', 'session_number']) }}
```

## Nulls and duplicates in the key

Key columns shouldn't contain nulls. A null may fail to match its row and create a duplicate. Use `coalesce()` to replace nulls, or build one key with `dbt_utils.generate_surrogate_key`.

The key must also be unique in both the existing table and the new rows, or the model can fail, depending on your warehouse and strategy. Add a `unique` test to catch this early.

## Which strategies use unique_key

`merge` and `delete+insert` use it. `merge` with no key behaves like `append`. `append` never checks for duplicates, and `insert_overwrite` replaces whole partitions instead of matching rows.

The strategies on offer depend on your adapter. Set one with `incremental_strategy` in the model's config.

## After you add a key

A key stops new duplicates, but it doesn't remove rows already loaded. Run one full refresh to rebuild the table.

```bash
dbt run --full-refresh --select fct_orders+
```

The trailing `+` also rebuilds downstream models. See [dbt's incremental models docs](https://docs.getdbt.com/docs/build/incremental-models) for more.

## What this rule checks

This rule flags incremental models with no `unique_key`, or an empty one. Models using `insert_overwrite`, `microbatch` or `append` are not judged, because those strategies never take a key. Every other strategy, including the default, is.

## Why it matters

A re-run that re-reads loaded rows appends them again, and totals drift upward until a full refresh. A `>=` watermark on `{{ this }}` re-reads the last period on every run. A strict `>` watermark is safe day to day, but a backfill still duplicates and late rows are skipped. We rate that shape medium rather than high.

## How to fix it

**Stop partial reloads from duplicating rows.** Set `unique_key` in the model config, then full-refresh once to clear the duplicates already loaded. If the model is partitioned by date, set `incremental_strategy: insert_overwrite` or `microbatch` instead; they replace whole partitions and need no key.

Before:

```sql
{{ config(materialized='incremental') }}

select order_id, status, updated_at
from {{ ref('stg_shop__orders') }}
{% if is_incremental() %}
where updated_at >= (select max(updated_at) from {{ this }})
{% endif %}
```

After:

```sql
{{ config(materialized='incremental', unique_key='order_id') }}

select order_id, status, updated_at
from {{ ref('stg_shop__orders') }}
{% if is_incremental() %}
where updated_at >= (select max(updated_at) from {{ this }})
{% endif %}
```

## When it is fine to leave

A true event log, where every row is new and a repeated row is a real repeat, has no key to merge on. Declare `incremental_strategy: append` so the choice is on record. This rule then stops flagging it.

## What we need to check it

`manifest.json`, which `dbt parse` writes. We tell the watermark shapes apart by reading each model's `raw_code`. A manifest without it puts every flagged model in the high-severity tier.

[Ask about a review](https://metacenta.com/enquire?src=rule-incremental-without-unique-key&via=md)

## Related pages

- [dbt public models with no enforced contract](https://metacenta.com/rules/public-model-without-contract)
- [dbt sources declared twice for one table](https://metacenta.com/rules/duplicate-source)
- [dbt staging models: how to set up the staging layer](https://metacenta.com/rules/staging-depends-on-staging)
- [dbt staging models that read marts](https://metacenta.com/rules/staging-depends-on-downstream)
- [This rule in a sample review](https://metacenta.com/sample#constructed)
- [All published rules](https://metacenta.com/rules)
- [How a review is assessed](https://metacenta.com/standard)
