Skip to content

Tutorial: dbt + Snowflake Identity Pipeline ​

Build a Customer 360 identity pipeline using dbt-kanoniv macros on Snowflake. You will normalize raw CRM and billing data with dbt, write a Kanoniv spec, reconcile with the Python SDK, and materialize golden records as dbt mart tables.

Time: 30 minutes Prerequisites: dbt 1.3+, Snowflake account, Python 3.9+, pip install kanoniv

What You Will Build ​

  1. Configure a dbt project with the dbt-kanoniv package
  2. Define Snowflake sources for raw CRM and Stripe tables
  3. Write staging models that normalize names, emails, and phones with dbt-kanoniv macros
  4. Author a Kanoniv spec with matching rules and survivorship
  5. Reconcile records locally with the Python SDK
  6. Enable dbt-kanoniv mart models for analytics

Step 1: Project Setup ​

Initialize a new dbt project and install the dbt-kanoniv package.

bash
dbt init customer_identity
cd customer_identity

Add dbt-kanoniv as a dependency:

packages.yml
packages: - git: "https://github.com/dreynow/dbt-kanoniv.git"revision: main

Install the package:

bash
dbt deps

Configure your dbt_project.yml to set the Snowflake profile and Kanoniv variables:

dbt_project.yml
name: customer_identityversion: '1.0.0'config-version: 2

profile: snowflake_identity

model-paths: ["models"] macro-paths: ["macros"]

vars: kanoniv_default_country: 'US'kanoniv_email_normalize_gmail: truekanoniv_enable_models: falsekanoniv_schema: 'identity'

Make sure your ~/.dbt/profiles.yml has a Snowflake profile:

yaml
snowflake_identity:
  target: dev
  outputs:
    dev:
      type: snowflake
      account: "{{ env_var('SNOWFLAKE_ACCOUNT') }}"
      user: "{{ env_var('SNOWFLAKE_USER') }}"
      password: "{{ env_var('SNOWFLAKE_PASSWORD') }}"
      database: ANALYTICS
      warehouse: TRANSFORM_WH
      schema: IDENTITY
      role: TRANSFORMER

Step 2: Define Sources ​

Assume you have two raw tables in Snowflake loaded by your existing data pipeline:

  • RAW.CRM_CONTACTS — CRM export with name, email, phone, company
  • RAW.STRIPE_CUSTOMERS — Stripe billing data with name, email, created date

Create a sources file so dbt can reference them:

sources.yml
version: 2

sources:

  • name: rawdatabase: ANALYTICSschema: RAWtables:
    • name: crm_contactsdescription: CRM contact export with name, email, phonecolumns:

      • name: iddescription: CRM contact primary key
      • name: name
      • name: email
      • name: phone
      • name: company
    • name: stripe_customersdescription: Stripe billing customer recordscolumns:

      • name: customer_iddescription: Stripe customer primary key
      • name: full_name
      • name: email
      • name: created_at

Verify the connection:

bash
dbt debug

Step 3: Staging Models ​

Create staging models that normalize raw data using dbt-kanoniv macros. These macros standardize names, emails, and phone numbers so that matching rules work consistently.

CRM staging model ​

stg_crm_contacts.sql
with source as ( 
<span class="hl-fn">select</span> * <span class="hl-fn">from</span> &#123;&#123; <span class="hl-fn">source</span>(<span class="hl-str">'raw'</span>, <span class="hl-str">'crm_contacts'</span>) &#125;&#125;

)

select id as contact_id, {{ dbt_kanoniv.normalize_name('name') }} as normalized_name, {{ dbt_kanoniv.normalize_email('email') }} as normalized_email, {{ dbt_kanoniv.normalize_phone('phone') }} as normalized_phone, {{ dbt_kanoniv.email_domain('email') }} as email_domain, {{ dbt_kanoniv.blocking_key(['email_domain', 'normalized_name']) }} as block_key, company, 'crm' as source_system

from source where email is not null

What each macro does:

  • normalize_name('name') — uppercases, trims, strips titles (Dr., Mr.) and suffixes (Jr., III)
  • normalize_email('email') — lowercases, trims, normalizes Gmail dot-addressing
  • normalize_phone('phone') — strips non-numeric characters, formats to E.164
  • email_domain('email') — extracts the domain portion after @
  • blocking_key([...]) — concatenates fields into a composite key to reduce comparison space

Stripe staging model ​

stg_stripe_customers.sql
with source as ( 
<span class="hl-fn">select</span> * <span class="hl-fn">from</span> &#123;&#123; <span class="hl-fn">source</span>(<span class="hl-str">'raw'</span>, <span class="hl-str">'stripe_customers'</span>) &#125;&#125;

)

select customer_id as contact_id, {{ dbt_kanoniv.normalize_name('full_name') }} as normalized_name, {{ dbt_kanoniv.normalize_email('email') }} as normalized_email, null as normalized_phone, {{ dbt_kanoniv.email_domain('email') }} as email_domain, {{ dbt_kanoniv.blocking_key(['email_domain', 'normalized_name']) }} as block_key, null as company, 'stripe' as source_system

from source where email is not null

Both models produce a uniform schema: contact_id, normalized_name, normalized_email, normalized_phone, email_domain, block_key, company, source_system. This consistency is what makes the Kanoniv spec work — all sources share the same field names.

Build the staging models:

bash
dbt run --select staging

Step 4: Write the Kanoniv Spec ​

Create a spec that tells Kanoniv how to match records across the two staged sources. The spec uses the dbt adapter which reads from dbt's manifest.json to locate the staging tables.

customer-spec.yaml
api_version: "kanoniv/v2"identity_version: "customer_360_v1"metadata: name: customer-360-snowflakedescription: Unify customer identities from CRM and Stripe via dbt staging models

entity: name: customer

sources:

  • name: crmadapter: dbtlocation: ref('stg_crm_contacts')primary_key: contact_idattributes: name: normalized_nameemail: normalized_emailphone: normalized_phonecompany: company

  • name: stripeadapter: dbtlocation: ref('stg_stripe_customers')primary_key: contact_idattributes: name: normalized_nameemail: normalized_email

rules:

  • name: email_exacttype: exactfield: emailweight: 1.0

  • name: name_fuzzytype: similarityfield: namealgorithm: jaro_winklerthreshold: 0.85weight: 0.6

survivorship: default: source_priorityoverrides: - field: namestrategy: source_prioritypriority: [crm, stripe] - field: emailstrategy: source_prioritypriority: [crm, stripe]

decision: thresholds: match: 0.85review: 0.60

Key points:

  • adapter: dbt — sources read from dbt-materialized tables via location: ref('model_name'). No CSV paths needed.
  • attributes — map canonical field names to the normalized column names from your staging models.
  • Two rules — exact email (weight 1.0) catches identical emails; fuzzy name with Jaro-Winkler (weight 0.6, threshold 0.85) catches variations like "Bob Johnson" vs "Robert Johnson".
  • source_priority survivorship — the default strategy is source_priority, with per-field overrides that prioritize CRM values, with Stripe filling in gaps.
  • Decision thresholds — 0.85+ is an automatic match; 0.60–0.85 goes to a review queue.

Step 5: Reconcile with the Python SDK ​

With staging models built and the spec written, run reconciliation locally using the Kanoniv Python SDK. The Source.from_dbt() adapter reads directly from the dbt manifest.

Validate the spec ​

python
from kanoniv import Spec, validate

spec = Spec.from_file("specs/customer-spec.yaml")

result = validate(spec)
result.raise_on_error()

print("Spec is valid!")
print(f"  Sources: {len(spec.sources)}")
print(f"  Rules: {len(spec.rules)}")

Load sources from dbt ​

python
from kanoniv import Source, reconcile

sources = [
    Source.from_dbt("crm", manifest="target/manifest.json", model="stg_crm_contacts"),
    Source.from_dbt("stripe", manifest="target/manifest.json", model="stg_stripe_customers"),
]

Source.from_dbt() reads the compiled manifest to locate each model's warehouse table, then queries Snowflake to pull the staged records into the local reconciliation engine.

Run reconciliation ​

python
result = reconcile(sources, spec)

print(f"Records processed: {result.total_records}")
print(f"Clusters found: {len(result.clusters)}")
print(f"Golden records: {len(result.golden_records)}")
print(f"Merge rate: {result.merge_rate:.1%}")

Inspect clusters ​

python
for cluster in result.clusters:
    if len(cluster.members) > 1:
        print(f"Cluster {cluster.id} ({len(cluster.members)} records):")
        for member in cluster.members:
            print(f"  [{member.source}] {member.fields.get('name', 'N/A')} "
                  f"<{member.fields.get('email', 'N/A')}>")
        print()

Export to DataFrame ​

python
df = result.to_pandas()
print(df[["golden_id", "name", "email", "company", "source_count"]].to_string(index=False))

The golden records now contain one canonical row per identity cluster, with CRM values winning via the source_priority survivorship default with per-field priority overrides.


Step 6: Analytics with dbt Marts ​

Once you have verified the reconciliation results, enable the built-in dbt-kanoniv mart models. These models read from the Kanoniv output tables and provide analytics-ready views.

Update dbt_project.yml to enable the models and point them at the correct schema:

dbt_project.yml
name: customer_identityversion: '1.0.0'config-version: 2

profile: snowflake_identity

model-paths: ["models"] macro-paths: ["macros"]

vars: kanoniv_default_country: 'US'kanoniv_email_normalize_gmail: truekanoniv_enable_models: true # <-- enabledkanoniv_schema: 'identity'kanoniv_database: 'ANALYTICS'

Build everything — your staging models plus the Kanoniv mart models:

bash
dbt build

The dbt build command runs your staging models first, then the Kanoniv package models. After it completes, you can query the analytics tables directly in Snowflake:

query_overview.sql
-- Canonical overview: one row per golden recordselect canonical_id, canonical_name, canonical_email, source_count, first_seen_at, last_seen_at from {{ ref('kanoniv__canonical_overview') }} order by source_count desclimit 20; 
query_duplicates.sql
-- Find records that were merged across sourcesselect canonical_id, source_system, source_id, normalized_name, normalized_email, match_score from {{ ref('kanoniv__identity_links') }} where match_score >= 0.85order by canonical_id, source_system; 

Production Workflow

In production, schedule dbt build on a cron (via dbt Cloud, Airflow, or Dagster). Each run picks up new raw records, normalizes them through staging, and the Kanoniv models keep the golden records up to date.


Summary ​

You built a complete identity resolution pipeline:

LayerToolWhat It Does
Data loadingSnowflake / FivetranLoads raw CRM and Stripe data
Normalizationdbt + dbt-kanoniv macrosStandardizes names, emails, phones
MatchingKanoniv specDeclares exact and fuzzy rules
ReconciliationKanoniv Python SDKClusters records, produces golden records
Analyticsdbt-kanoniv mart modelsMaterialized tables for BI dashboards

Next Steps ​

The identity and delegation layer for AI agents.