Skip to content

Tutorial: Snowflake + dbt + Kanoniv Cloud

Build a production identity resolution pipeline that extracts customer data from Snowflake, normalizes it with dbt, reconciles identities through Kanoniv Cloud, and writes the golden records back to Snowflake - all in a single Python script.

Time: 45 minutes Difficulty: Intermediate

What You Will Build

Snowflake (3 sources)  ->  dbt (stage + normalize)  ->  Kanoniv Cloud (reconcile)  ->  Snowflake (golden records)
  1. Three raw Snowflake tables representing CRM contacts, billing customers, and support tickets
  2. dbt staging models that normalize names, emails, and phone numbers
  3. A Kanoniv spec that defines matching rules across all three sources
  4. A Python script that reads staged data, sends it to Kanoniv Cloud for reconciliation, and writes golden records back to Snowflake

Prerequisites

  • Snowflake account with a warehouse and a database you can write to
  • dbt Core 1.3+ configured with a Snowflake profile (pip install dbt-snowflake)
  • Python 3.9+ with the cloud extras: pip install kanoniv[cloud] snowflake-connector-python pandas
  • Kanoniv Cloud API key - sign up at app.kanoniv.com and generate a key from Settings

Step 1: The Raw Data in Snowflake

Assume your data pipelines (Fivetran, Airbyte, or custom ETL) have loaded three raw tables into Snowflake:

RAW.CRM_CONTACTS - Salesforce CRM

IDNAMEEMAILPHONECOMPANYCREATED_AT
sf_001John Doe[email protected]555-0101Acme Corp2025-01-15
sf_002Jane Smith[email protected]555-0102Globex Inc2025-02-20
sf_003Bob Wilson[email protected]555-0103Acme Corp2025-03-10
sf_004Alice Brown[email protected](555) 010-4Startup LLC2025-04-05

RAW.BILLING_CUSTOMERS - Stripe

CUSTOMER_IDFULL_NAMEEMAILPLANMRRCREATED_AT
cus_001Jonathan Doe[email protected]enterprise4992025-01-20
cus_002Jane Smith[email protected]pro992025-03-01
cus_003R. Wilson[email protected]starter292025-03-15
cus_005Eve Martinez[email protected]pro992025-05-01

RAW.SUPPORT_TICKETS - Zendesk

TICKET_IDREQUESTER_NAMEREQUESTER_EMAILPRIORITYSTATUSCREATED_AT
tkt_100J. Doe[email protected]highclosed2025-02-01
tkt_101Jane S.[email protected]mediumopen2025-04-10
tkt_102Alice B[email protected]lowclosed2025-05-15
tkt_103Eve M[email protected]highopen2025-06-01

Notice the overlaps: "John Doe" / "Jonathan Doe" / "J. Doe" all share the same email. "Bob Wilson" / "R. Wilson" share an email too. These are the same people represented differently across three systems.


Step 2: dbt Project Setup

Initialize a dbt project and install the dbt-kanoniv package for normalization macros.

bash
dbt init customer_identity
cd customer_identity

Add the package dependency:

packages.yml

yaml
packages:
  - git: "https://github.com/kanoniv/dbt-kanoniv.git"
    revision: main
bash
dbt deps

Configure the project:

dbt_project.yml

yaml
name: customer_identity
version: '1.0.0'
config-version: 2

profile: snowflake_identity

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

vars:
  kanoniv_default_country: 'US'
  kanoniv_email_normalize_gmail: true
  kanoniv_enable_models: false
  kanoniv_schema: 'identity'

Make sure your ~/.dbt/profiles.yml has the Snowflake connection:

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 3: Define Sources

Create a dbt sources file that references the three raw tables:

models/sources.yml

yaml
version: 2

sources:
  - name: raw
    database: ANALYTICS
    schema: RAW
    tables:
      - name: crm_contacts
        description: Salesforce CRM contact records
        columns:
          - name: id
            description: Salesforce contact ID
          - name: name
          - name: email
          - name: phone
          - name: company
          - name: created_at

      - name: billing_customers
        description: Stripe billing customer records
        columns:
          - name: customer_id
            description: Stripe customer ID
          - name: full_name
          - name: email
          - name: plan
          - name: mrr
          - name: created_at

      - name: support_tickets
        description: Zendesk support ticket records
        columns:
          - name: ticket_id
            description: Zendesk ticket ID
          - name: requester_name
          - name: requester_email
          - name: priority
          - name: status
          - name: created_at

Verify the connection:

bash
dbt debug

Step 4: Staging Models

Create staging models that normalize each source into a consistent schema using dbt-kanoniv macros.

CRM staging model

models/staging/stg_crm_contacts.sql

sql
with source as (

    select * from {{ source('raw', 'crm_contacts') }}

)

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,
    company,
    created_at,
    'crm'                                           as source_system

from source
where email is not null

Billing staging model

models/staging/stg_billing_customers.sql

sql
with source as (

    select * from {{ source('raw', 'billing_customers') }}

)

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,
    null                                                 as company,
    created_at,
    'billing'                                            as source_system

from source
where email is not null

Support staging model

models/staging/stg_support_tickets.sql

sql
with source as (

    select * from {{ source('raw', 'support_tickets') }}

)

select
    ticket_id                                                 as contact_id,
    {{ dbt_kanoniv.normalize_name('requester_name') }}        as normalized_name,
    {{ dbt_kanoniv.normalize_email('requester_email') }}      as normalized_email,
    null                                                      as normalized_phone,
    null                                                      as company,
    created_at,
    'support'                                                 as source_system

from source
where requester_email is not null

All three models produce the same output schema: contact_id, normalized_name, normalized_email, normalized_phone, company, created_at, source_system. This consistency is what makes the Kanoniv spec work across all sources.

Build the staging models:

bash
dbt run --select staging

Step 5: Write the Kanoniv Spec

Create a spec that defines how to match records across the three staged sources.

specs/customer-cloud.yaml

yaml
api_version: kanoniv/v1
identity_version: "1.0"

entity:
  name: customer

sources:
  crm:
    adapter: snowflake
    primary_key: contact_id
    schema:
      name: { type: string }
      email: { type: string, pii: true }
      phone: { type: string, pii: true }
      company: { type: string }

  billing:
    adapter: snowflake
    primary_key: contact_id
    schema:
      name: { type: string }
      email: { type: string, pii: true }

  support:
    adapter: snowflake
    primary_key: contact_id
    schema:
      name: { type: string }
      email: { type: string, pii: true }

rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0

  - name: name_fuzzy
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.85
    weight: 0.6

  - name: phone_exact
    type: exact
    field: phone
    weight: 0.8

decision:
  scoring: weighted_sum
  thresholds:
    match: 0.85
    review: 0.60

survivorship:
  strategy: source_priority
  source_order: [crm, billing, support]

Key design decisions:

  • Three rules - exact email is the strongest signal (weight 1.0); fuzzy name catches variations like "John Doe" / "J. Doe" (weight 0.6, Jaro-Winkler 0.85 threshold); exact phone provides an additional linkage path (weight 0.8)
  • Source priority - CRM is the system of record, billing fills in gaps, support is lowest priority
  • Match threshold at 0.85 - email match alone (1.0) exceeds the threshold; name similarity alone (0.6) does not, which prevents false positives from common names
  • Review band at 0.60-0.85 - edge cases land in a review queue instead of being auto-merged or discarded

Step 6: Reconcile with Kanoniv Cloud

This is the core script. It reads the staged data from Snowflake, sends it to Kanoniv Cloud for reconciliation, and writes the golden records back.

reconcile_cloud.py

python
import os

import pandas as pd
import snowflake.connector

import kanoniv
from kanoniv import Source, Spec


# ── Configuration ─────────────────────────────────────────────
SNOWFLAKE_CONFIG = {
    "account": os.environ["SNOWFLAKE_ACCOUNT"],     # e.g. "xy12345.us-east-1"
    "user": os.environ["SNOWFLAKE_USER"],
    "password": os.environ["SNOWFLAKE_PASSWORD"],
    "database": "ANALYTICS",
    "schema": "IDENTITY",
    "warehouse": "TRANSFORM_WH",
}

KANONIV_API_KEY = os.environ["KANONIV_API_KEY"]      # e.g. "kn_live_abc123..."


# ── Step 1: Read staged data from Snowflake ──────────────────
def read_staged_table(conn, table_name: str) -> pd.DataFrame:
    """Read a staged dbt model from Snowflake into a DataFrame."""
    query = f"SELECT * FROM ANALYTICS.IDENTITY.{table_name}"
    cursor = conn.cursor()
    cursor.execute(query)
    columns = [desc[0].lower() for desc in cursor.description]
    rows = cursor.fetchall()
    return pd.DataFrame(rows, columns=columns)


conn = snowflake.connector.connect(**SNOWFLAKE_CONFIG)

print("Reading staged data from Snowflake...")
df_crm = read_staged_table(conn, "STG_CRM_CONTACTS")
df_billing = read_staged_table(conn, "STG_BILLING_CUSTOMERS")
df_support = read_staged_table(conn, "STG_SUPPORT_TICKETS")

print(f"  CRM:     {len(df_crm):,} records")
print(f"  Billing: {len(df_billing):,} records")
print(f"  Support: {len(df_support):,} records")


# ── Step 2: Build Kanoniv sources from DataFrames ────────────
# Rename normalized columns to match the spec's schema field names
crm_source = Source.from_pandas(
    "crm",
    df_crm.rename(columns={
        "normalized_name": "name",
        "normalized_email": "email",
        "normalized_phone": "phone",
    }),
    primary_key="contact_id",
)

billing_source = Source.from_pandas(
    "billing",
    df_billing.rename(columns={
        "normalized_name": "name",
        "normalized_email": "email",
    }),
    primary_key="contact_id",
)

support_source = Source.from_pandas(
    "support",
    df_support.rename(columns={
        "normalized_name": "name",
        "normalized_email": "email",
    }),
    primary_key="contact_id",
)


# ── Step 3: Load the spec and reconcile on Kanoniv Cloud ─────
spec = Spec.from_file("specs/customer-cloud.yaml")

print("\nSubmitting to Kanoniv Cloud...")
with kanoniv.cloud.reconcile(
    [crm_source, billing_source, support_source],
    spec,
    api_key=KANONIV_API_KEY,
) as result:
    print(result.summary())
    # Cloud Reconciliation - job a1b2c3d4-...
    #   Status:          completed
    #   Health:          healthy
    #   Input entities:  12
    #   Canonicals:      7
    #   Merge rate:      41.7%

    # ── Step 4: Fetch golden records ─────────────────────────
    df_golden = result.to_pandas()
    print(f"\nGolden records: {len(df_golden)}")
    print(df_golden[["canonical_id", "name", "email", "company"]].to_string(index=False))


# ── Step 5: Write golden records back to Snowflake ───────────
print("\nWriting golden records to Snowflake...")

cursor = conn.cursor()

# Create the destination table if it does not exist
cursor.execute("""
    CREATE TABLE IF NOT EXISTS ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS (
        canonical_id    VARCHAR(64)   PRIMARY KEY,
        name            VARCHAR(256),
        email           VARCHAR(256),
        phone           VARCHAR(32),
        company         VARCHAR(256),
        source_count    INTEGER,
        reconciled_at   TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
    )
""")

# Truncate and reload (full-refresh pattern)
cursor.execute("TRUNCATE TABLE ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS")

# Insert golden records
insert_sql = """
    INSERT INTO ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS
        (canonical_id, name, email, phone, company, source_count)
    VALUES (%(canonical_id)s, %(name)s, %(email)s, %(phone)s,
            %(company)s, %(source_count)s)
"""

records = df_golden.to_dict("records")
for record in records:
    cursor.execute(insert_sql, {
        "canonical_id": record.get("canonical_id", ""),
        "name": record.get("name", ""),
        "email": record.get("email", ""),
        "phone": record.get("phone", ""),
        "company": record.get("company", ""),
        "source_count": record.get("source_count", 1),
    })

conn.commit()
print(f"Wrote {len(records)} golden records to ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS")


# ── Step 6: Query the live identity graph ────────────────────
from kanoniv import Client

with Client(api_key=KANONIV_API_KEY) as client:
    # Resolve a CRM contact to its canonical identity
    result = client.resolve(system="crm", external_id="sf_001")
    print(f"\nCRM contact sf_001 resolved to canonical: {result['canonical_id']}")

    # See every linked record across all three systems
    linked = client.entities.get_linked(result["canonical_id"])
    print("Linked records:")
    for record in linked["linked"]:
        print(f"  [{record['source_name']}] {record['external_id']}")
    # [crm] sf_001
    # [billing] cus_001
    # [support] tkt_100

conn.close()
print("\nDone.")

Optional: Arrow Fast Path

If you install the dataplane extra, the same pipeline runs ~6x faster using columnar Arrow reads and Parquet bulk ingest instead of row-by-row JSON uploads.

bash
pip install kanoniv[cloud,dataplane]

With the dataplane extra installed, you can replace the manual read_staged_table() + Source.from_pandas() approach with Source.from_warehouse(). The Arrow path is auto-detected:

python
import kanoniv
from kanoniv import Source, Spec

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

# Source.from_warehouse() replaces manual Snowflake reads + Source.from_pandas()
crm = Source.from_warehouse(
    "crm",
    "ANALYTICS.IDENTITY.STG_CRM_CONTACTS",
    connection_string="snowflake://user:pass@acct/ANALYTICS/IDENTITY",
)

billing = Source.from_warehouse(
    "billing",
    "ANALYTICS.IDENTITY.STG_BILLING_CUSTOMERS",
    connection_string="snowflake://user:pass@acct/ANALYTICS/IDENTITY",
)

support = Source.from_warehouse(
    "support",
    "ANALYTICS.IDENTITY.STG_SUPPORT_TICKETS",
    connection_string="snowflake://user:pass@acct/ANALYTICS/IDENTITY",
)

# Arrow path is used automatically - same API, faster ingest
with kanoniv.cloud.reconcile(
    [crm, billing, support],
    spec,
    api_key=os.environ["KANONIV_API_KEY"],
) as result:
    print(result.summary())
    df = result.to_pandas()

What changes under the hood:

StepWithout dataplaneWith dataplane
Read from SnowflakeRow-by-row cursor + pandasColumnar Arrow fetch
Column mappingManual .rename(columns={...})Automatic via spec attribute mappings
Upload to API500-record JSON batchesParquet file per source
Server ingestRow-by-row INSERTCOPY FROM Parquet

The column renaming from normalized_name to name is handled automatically by the staging layer using your spec's attribute mappings - no manual .rename() needed.

See the Arrow Data Plane guide for the full architecture and manual pipeline options.

Run it:

bash
export SNOWFLAKE_ACCOUNT="xy12345.us-east-1"
export SNOWFLAKE_USER="KANONIV_SVC"
export SNOWFLAKE_PASSWORD="your-password"
export KANONIV_API_KEY="kn_live_abc123..."

python reconcile_cloud.py

Expected output:

Reading staged data from Snowflake...
  CRM:     4 records
  Billing: 4 records
  Support: 4 records

Submitting to Kanoniv Cloud...
Cloud Reconciliation - job a1b2c3d4-e5f6-7890-abcd-ef1234567890
  Status:          completed
  Duration:        1,240ms
  Health:          healthy
  Input entities:  12
  Canonicals:      5
  Links:           12
  Merge rate:      58.3%

Golden records: 5
     canonical_id            name              email       company
 c7e2f9a1-...        John Doe      [email protected]     Acme Corp
 b3d4e5f6-...      Jane Smith  [email protected]  Globex Inc
 a1b2c3d4-...      Bob Wilson       [email protected]     Acme Corp
 d5e6f7g8-...     Alice Brown    [email protected]   Startup LLC
 e7f8g9h0-...    Eve Martinez       [email protected]          None

Writing golden records to Snowflake...
Wrote 5 golden records to ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS

CRM contact sf_001 resolved to canonical: c7e2f9a1-...
Linked records:
  [crm] sf_001
  [billing] cus_001
  [support] tkt_100

Done.

Step 7: Real-Time Resolution with Probabilistic Scoring

After the batch reconciliation in Step 6, your identity graph is live. But the real power is what happens next: when a new record arrives between batch runs, you can resolve it against the existing graph in real time using the same Fellegi-Sunter probabilistic model that powers your batch pipeline.

The POST /v1/resolve/realtime endpoint uses your compiled spec to score incoming records against existing canonical entities. It generates blocking keys, retrieves candidates, and applies the full FS scoring pipeline - not just exact field matching.

reconcile_realtime.py

python
import os
from kanoniv import Client

KANONIV_API_KEY = os.environ["KANONIV_API_KEY"]

with Client(api_key=KANONIV_API_KEY) as client:
    # A new support ticket arrives between batch runs.
    # Resolve it against the existing identity graph.
    result = client.post("/v1/resolve/realtime", json={
        "source": "support",
        "external_id": "tkt_200",
        "fields": {
            "name": "Jonathan Doe",
            "email": "[email protected]",
            "company": "Acme Corp",
        },
    })

    if result.get("matched"):
        print(f"Matched to canonical entity: {result['canonical_id']}")
        print(f"  Score: {result['score']:.2f}")
        print(f"  Matched on: {result['matched_on']}")
        # Matched to canonical entity: c7e2f9a1-...
        #   Score: 11.4
        #   Matched on: ['email_exact', 'name_jaro_winkler', 'company_last_name']
    else:
        print(f"New entity created: {result['canonical_id']}")

    # When a record bridges two existing entities, the endpoint
    # can merge them inline if the scores exceed merge_threshold.
    bridging = client.post("/v1/resolve/realtime", json={
        "source": "billing",
        "external_id": "cus_new_001",
        "fields": {
            "name": "Alice Brown",
            "email": "[email protected]",
            "phone": "555-0104",
        },
    })

    if bridging.get("merged_entities"):
        print(f"Merged {len(bridging['merged_entities'])} entities into {bridging['canonical_id']}")

How it works under the hood:

  1. Blocking key generation. The endpoint generates composite blocking keys from the input fields (email, phone, last_name+first_name, company+last_name) and queries canonical_entities via SQL to find candidates.

  2. Fellegi-Sunter scoring. Each candidate is scored using the same compiled FS plan from your spec - with the same normalizers (email, phone, name, nickname, domain) and comparators (exact, jaro_winkler, levenshtein, soundex, metaphone, cosine).

  3. Two-threshold decision. The endpoint uses two thresholds:

    • match_threshold (from your spec's scoring.thresholds.match) - for linking the new record to an existing entity
    • merge_threshold (from scoring.thresholds.merge, defaults to match * 1.5) - for merging two existing entities when the new record bridges them
  4. Inline merge. If the new record matches N existing entities above merge_threshold, the losers are merged into the highest-scoring winner with a full audit trail (entity_merges, entity_events).

  5. Fallback. If no compiled FS plan exists (e.g., you have not run a batch reconciliation yet), the endpoint falls back to exact email/phone matching. This keeps the API backwards compatible.

You can configure the merge threshold in your spec:

yaml
scoring:
  method: fellegi_sunter
  thresholds:
    match: 8.0
    possible: 4.0
    non_match: -4.0
    merge: 12.0  # optional, defaults to match * 1.5

The higher merge threshold prevents accidental entity merges from borderline scores. Only high-confidence bridging records trigger inline merges.


What Just Happened?

Here is the full flow, step by step:

1. Extract (Snowflake -> dbt)

Your raw data lives in three Snowflake tables loaded by separate pipelines. The dbt staging models applied dbt-kanoniv normalization macros to standardize names (uppercase, strip titles), emails (lowercase, Gmail dot-trick), and phone numbers (E.164 format). This preprocessing is critical - without it, "John Doe" and "J. Doe" would never be compared.

2. Load (Snowflake -> Python)

The Python script read the staged tables into pandas DataFrames and wrapped them as Kanoniv Source objects. Column names were remapped from normalized_name to name to match the spec's schema declarations.

3. Upload (Python -> Kanoniv Cloud)

kanoniv.cloud.reconcile() handled the upload automatically:

  • Uploaded the YAML spec to the API
  • Created source registrations on the server
  • Batched the records (500 per request) and ingested them via /v1/ingest/batch

4. Reconcile (Kanoniv Cloud)

The cloud engine ran the full reconciliation pipeline:

  • Block - grouped records by email domain to reduce the comparison space
  • Compare - applied all three rules (exact email, fuzzy name, exact phone) to every candidate pair within each block
  • Score - computed weighted scores: email match = 1.0, name similarity = 0.6 * Jaro-Winkler, phone match = 0.8
  • Decide - pairs scoring above 0.85 were auto-matched; pairs between 0.60 and 0.85 went to review
  • Cluster - connected components formed identity clusters (e.g., sf_001 + cus_001 + tkt_100 all linked by email)
  • Survive - CRM fields won via source priority; billing and support fields filled gaps

5. Write Back (Python -> Snowflake)

The script fetched the golden records from the cloud API via result.to_pandas() and wrote them to ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS using a truncate-and-reload pattern.

6. Live Resolution

After reconciliation, the identity graph stays live on Kanoniv Cloud. Any downstream service can call client.resolve(system="crm", external_id="sf_001") to get the canonical identity in real time - no re-running the pipeline.

7. Real-Time Probabilistic Resolution

The POST /v1/resolve/realtime endpoint goes further. When a new record arrives between batch runs, it scores the record against existing canonical entities using the same Fellegi-Sunter model from your spec. If the record matches an entity above match_threshold, it is linked. If it bridges multiple entities above merge_threshold, they are merged inline with a full audit trail. No batch re-run needed.


Scheduling in Production

For production, wrap the reconciliation in an orchestrator so it runs after dbt completes.

Airflow example

python
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from datetime import datetime

with DAG("identity_resolution", schedule="0 6 * * *", start_date=datetime(2026, 1, 1)):

    dbt_run = BashOperator(
        task_id="dbt_staging",
        bash_command="cd /opt/dbt/customer_identity && dbt run --select staging",
    )

    reconcile = BashOperator(
        task_id="reconcile_cloud",
        bash_command="python /opt/scripts/reconcile_cloud.py",
    )

    dbt_run >> reconcile

dbt Cloud + webhook

If you use dbt Cloud, trigger the Python script via a dbt Cloud webhook that fires on successful run completion.


Querying the Results in Snowflake

Once golden records are written back, your analysts and BI tools can query them directly.

Customer 360 view

sql
-- Join golden records with source data for a full Customer 360
SELECT
    g.canonical_id,
    g.name,
    g.email,
    g.company,
    g.source_count,
    b.plan,
    b.mrr,
    s.priority AS last_ticket_priority,
    s.status   AS last_ticket_status
FROM ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS g
LEFT JOIN ANALYTICS.RAW.BILLING_CUSTOMERS b
    ON g.email = LOWER(b.email)
LEFT JOIN (
    SELECT DISTINCT ON (requester_email) *
    FROM ANALYTICS.RAW.SUPPORT_TICKETS
    ORDER BY requester_email, created_at DESC
) s
    ON g.email = LOWER(s.requester_email)
ORDER BY g.source_count DESC;

Duplicate report

sql
-- Find customers matched across multiple systems
SELECT
    canonical_id,
    name,
    email,
    source_count
FROM ANALYTICS.IDENTITY.GOLDEN_CUSTOMERS
WHERE source_count > 1
ORDER BY source_count DESC;

Troubleshooting

"No records returned from staging table" - Make sure dbt run --select staging completed successfully. Check that the tables exist in ANALYTICS.IDENTITY with SHOW TABLES IN SCHEMA ANALYTICS.IDENTITY.

"Cloud reconciliation job timed out" - The default timeout is 300 seconds. For larger datasets, increase it: kanoniv.cloud.reconcile(..., timeout=600.0). You can also lower the poll interval: poll_interval=1.0.

"Health status: degraded" - This usually means the merge rate is unusually high or low, or too many records landed in the review queue. Check result.health_flags for specific signals and adjust your thresholds or rules accordingly.

"Column name mismatch" - The Source.from_pandas() column names must match the field names in your spec's schema block. Use .rename(columns={...}) to map the dbt normalized column names to the spec's expected names.


Next Steps

  • Arrow Data Plane - 6x faster warehouse-to-cloud ingest with Arrow + DuckDB + Parquet
  • Active Learning - Iteratively improve match quality by labeling uncertain pairs
  • Tuning Match Quality - Diagnose false positives and false negatives, iterate on precision and recall
  • Spec Reference - Full reference for every spec option including Fellegi-Sunter probabilistic matching
  • Kanoniv Cloud - Complete cloud SDK reference with entity lifecycle, audit, and review queue APIs
  • dbt + Snowflake Tutorial - The local-only version of this pipeline using Source.from_dbt()

The identity and delegation layer for AI agents.