Skip to content

Tutorial: Build a Customer 360

Build a unified customer profile from three data sources: a CRM export, Stripe billing data, and Zendesk support tickets. By the end of this tutorial you will have a single set of golden records that merge overlapping customer identities across all three systems.

Time: 15 minutes Prerequisites: Python 3.9+, pip install kanoniv

What You Will Build

In this tutorial you will:

  1. Create three CSV data sources with intentionally overlapping records
  2. Write a matching spec with exact email and fuzzy name rules
  3. Validate the spec locally — no server required
  4. Run reconciliation and inspect clusters
  5. Extract golden records as a pandas DataFrame
  6. Iterate on rules to improve match quality

Step 1: Create Sample Data

Create three CSV files that simulate exports from different systems. The data has intentional overlaps — the same person appears across sources with slight name variations, different phone formats, and missing fields.

crm_contacts.csv

csv
id,name,email,phone,company
CRM-001,John Doe,[email protected],+1-555-0101,Acme Corp
CRM-002,Jane Smith,[email protected],+1-555-0102,Globex Inc
CRM-003,Robert Johnson,[email protected],+1-555-0103,Initech
CRM-004,Maria Garcia,[email protected],+1-555-0104,Hooli
CRM-005,Sarah Connor,[email protected],+1-555-0105,Cyberdyne Systems
CRM-006,Wei Zhang,[email protected],+1-555-0106,Pied Piper
CRM-007,Emily Davis,[email protected],,Umbrella Corp

stripe_customers.csv

csv
customer_id,full_name,email,card_last4
STRIPE-101,Jonathan Doe,[email protected],4242
STRIPE-102,Jane Smith,[email protected],1234
STRIPE-103,Bob Johnson,[email protected],5678
STRIPE-104,Maria G.,[email protected],9012
STRIPE-105,Sarah Connor,[email protected],3456
STRIPE-106,Emily Davis,[email protected],7890
STRIPE-107,Thomas Anderson,[email protected],2468
STRIPE-108,Lisa Wong,[email protected],1357

zendesk_users.csv

csv
user_id,display_name,email_address,organization
ZD-201,John D.,[email protected],Acme Corp
ZD-202,J. Smith,[email protected],Globex
ZD-203,Robert Johnson,[email protected],Initech LLC
ZD-204,Maria Garcia,[email protected],Hooli
ZD-205,S. Connor,[email protected],Cyberdyne
ZD-206,W. Zhang,[email protected],Pied Piper Inc
ZD-207,Tom Anderson,[email protected],Meta Cortex

Save each file to your working directory. Notice the overlaps:

  • John Doe appears as "John Doe" (CRM), "Jonathan Doe" (Stripe), and "John D." (Zendesk) — all sharing [email protected]
  • Robert Johnson uses [email protected] in CRM and Stripe, but [email protected] in Zendesk
  • Sarah Connor has a slightly different email in Stripe (sarah.connor@ vs s.connor@)

These variations are exactly what identity resolution is designed to handle.


Step 2: Write the Spec

Create a file called customer-360.yaml:

yaml
api_version: kanoniv/v1
identity_version: customer-360-v1

entity:
  name: customer
  description: Unify customer identities across CRM, billing, and support

sources:
  - name: crm
    adapter: csv
    location: crm_contacts.csv
    primary_key: id
    attributes:
      name: name
      email: email
      phone: phone
      company: company

  - name: billing
    adapter: csv
    location: stripe_customers.csv
    primary_key: customer_id
    attributes:
      name: full_name
      email: email

  - name: support
    adapter: csv
    location: zendesk_users.csv
    primary_key: user_id
    attributes:
      name: display_name
      email: email_address
      company: organization

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

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

survivorship:
  default: source_priority
  overrides:
    - field: name
      strategy: source_priority
      priority: [crm, billing, support]
    - field: email
      strategy: source_priority
      priority: [crm, billing, support]
    - field: company
      strategy: source_priority
      priority: [crm, billing, support]

decision:
  thresholds:
    match: 0.85
    review: 0.6

The spec defines:

  • Three sources with attribute mappings — each system uses different column names, but the spec normalizes them to a common schema (name, email, company) via the attributes map.
  • Entity — defines the entity type as customer.
  • Two rules — an exact email match (weight 1.0) and a fuzzy name match using the Jaro-Winkler algorithm (weight 0.6, fires when similarity exceeds 0.82).
  • Survivorship — the default strategy is source_priority, with per-field overrides that prefer CRM values first, then billing, then support.
  • Decision thresholds — a combined score of 0.85 or above is an automatic match; 0.60 to 0.85 goes to a review queue.

Step 3: Validate the Spec

Before running reconciliation, validate that the spec is well-formed and internally consistent.

python
from kanoniv import Spec, validate

spec = Spec.from_file("customer-360.yaml")

result = validate(spec)
result.raise_on_error()

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

Expected output:

Spec is valid!
  Sources: 3
  Rules: 2

If validation fails, raise_on_error() throws a SpecValidationError with details about what went wrong — for example, a rule referencing a field that does not exist in any source.


Step 4: Preview the Plan

The plan() function shows you what Kanoniv will do without actually running reconciliation. This is useful for reviewing the execution strategy before processing large datasets.

python
from kanoniv import plan

execution = plan(spec)

print(f"Plan hash: {execution.hash}")
print(f"Sources to load: {execution.source_count}")
print(f"Rules to apply: {execution.rule_count}")
print(f"Estimated comparisons: {execution.estimated_comparisons}")
print()
print("Execution order:")
for step in execution.steps:
    print(f"  {step.order}. {step.description}")

Expected output:

Plan hash: a3f7c2e1
Sources to load: 3
Rules to apply: 2
Estimated comparisons: 176

Execution order:
  1. Load source 'crm' (csv: crm_contacts.csv)
  2. Load source 'billing' (csv: stripe_customers.csv)
  3. Load source 'support' (csv: zendesk_users.csv)
  4. Apply rule 'email_exact' (exact on [email])
  5. Apply rule 'name_fuzzy' (fuzzy/jaro_winkler on [name])
  6. Score candidate pairs (match >= 0.85, review >= 0.6)
  7. Apply survivorship (default: source_priority, 3 overrides)
  8. Emit golden records

The plan hash changes whenever the spec changes — useful for caching and CI/CD change detection.


Step 5: Run Reconciliation

Now run the actual reconciliation. The reconcile() function loads data from all sources, applies rules, scores pairs, and produces golden records — all locally, with no network calls.

python
from kanoniv import Source, reconcile

sources = [
    Source.from_csv("crm", "crm_contacts.csv"),
    Source.from_csv("billing", "stripe_customers.csv"),
    Source.from_csv("support", "zendesk_users.csv"),
]

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%}")

Expected output:

Records processed: 22
Clusters found: 9
Golden records: 9
Merge rate: 59.1%

A merge rate of 59.1% means that more than half the input records were merged into clusters with other records. The remaining records were unique — they appeared in only one source and did not match anyone else.


Step 6: Inspect Results

Clusters

Clusters are groups of records that Kanoniv determined belong to the same entity. Each cluster contains the source records that were matched together.

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()

Expected output:

Cluster c-001 (3 records):
  [crm] John Doe <[email protected]>
  [billing] Jonathan Doe <[email protected]>
  [support] John D. <[email protected]>

Cluster c-002 (3 records):
  [crm] Jane Smith <[email protected]>
  [billing] Jane Smith <[email protected]>
  [support] J. Smith <[email protected]>

Cluster c-003 (2 records):
  [crm] Robert Johnson <[email protected]>
  [billing] Bob Johnson <[email protected]>

Cluster c-004 (3 records):
  [crm] Maria Garcia <[email protected]>
  [billing] Maria G. <[email protected]>
  [support] Maria Garcia <[email protected]>

Cluster c-005 (2 records):
  [crm] Sarah Connor <[email protected]>
  [support] S. Connor <[email protected]>

Cluster c-006 (2 records):
  [billing] Thomas Anderson <[email protected]>
  [support] Tom Anderson <[email protected]>

Notice that Robert Johnson from Zendesk ([email protected]) did not match — the email is different and the name alone was not enough to exceed the match threshold. We will fix this in Step 7.

Golden Records

Golden records are the survivorship-merged output — one canonical record per cluster.

python
for gr in result.golden_records:
    print(f"  {gr.id}: {gr.fields}")

Expected output (abbreviated):

  g-001: {'name': 'John Doe', 'email': '[email protected]', 'phone': '+1-555-0101', 'company': 'Acme Corp'}
  g-002: {'name': 'Jane Smith', 'email': '[email protected]', 'phone': '+1-555-0102', 'company': 'Globex Inc'}
  g-003: {'name': 'Robert Johnson', 'email': '[email protected]', 'phone': '+1-555-0103', 'company': 'Initech'}
  g-004: {'name': 'Maria Garcia', 'email': '[email protected]', 'phone': '+1-555-0104', 'company': 'Hooli'}
  ...

Because the survivorship default is source_priority with per-field overrides prioritizing [crm, billing, support], the CRM values win when they exist. The golden record for John Doe takes the CRM name "John Doe" (not "Jonathan Doe" from Stripe).

Convert to DataFrame

For further analysis, export the golden records to a pandas DataFrame:

python
df = result.to_pandas()
print(df.to_string(index=False))

Expected output:

 golden_id             name                   email         phone          company  source_count
     g-001         John Doe     [email protected]   +1-555-0101        Acme Corp             3
     g-002       Jane Smith  [email protected]   +1-555-0102       Globex Inc             3
     g-003  Robert Johnson  [email protected]  +1-555-0103          Initech             2
     g-004    Maria Garcia   [email protected]  +1-555-0104            Hooli             3
     g-005    Sarah Connor    [email protected]  +1-555-0105 Cyberdyne Systems            2
     g-006      Wei Zhang  [email protected]  +1-555-0106       Pied Piper             2
     g-007     Emily Davis     [email protected]         None    Umbrella Corp             2
     g-008 Thomas Anderson  [email protected]        None             None             2
     g-009      Lisa Wong  [email protected]         None             None             1

Step 7: Iterate — Add a Phone Rule

Robert Johnson from Zendesk did not match because his email was different. Let us add a fuzzy phone rule to catch cases where email differs but phone numbers overlap.

Update customer-360.yaml by adding a third rule:

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

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

  - name: phone_fuzzy
    type: similarity
    field: phone
    algorithm: levenshtein
    threshold: 0.85
    weight: 0.5

Also lower the match threshold slightly to allow the combined name + phone signal to produce matches:

yaml
decision:
  thresholds:
    match: 0.80
    review: 0.5

Re-run reconciliation:

python
spec_v2 = Spec.from_file("customer-360.yaml")
result_v2 = reconcile(sources, spec_v2)

print(f"Clusters: {len(result_v2.clusters)}")
print(f"Golden records: {len(result_v2.golden_records)}")
print(f"Merge rate: {result_v2.merge_rate:.1%}")

Expected output:

Clusters: 8
Golden records: 8
Merge rate: 63.6%

The cluster count dropped from 9 to 8 — Robert Johnson from Zendesk is now matched into the existing Initech cluster. The merge rate improved from 59.1% to 63.6%.

Iteration Workflow

The validate-plan-reconcile loop is designed for rapid iteration. Change a rule, re-run, and compare merge rates until the results meet your quality bar.


Full Working Script

Here is the complete script in one block:

python
from kanoniv import Source, Spec, reconcile, validate, plan

# Load and validate spec
spec = Spec.from_file("customer-360.yaml")
validate(spec).raise_on_error()

# Preview plan
execution = plan(spec)
print(f"Plan: {execution.source_count} sources, {execution.rule_count} rules")
print(f"Estimated comparisons: {execution.estimated_comparisons}")

# Load sources
sources = [
    Source.from_csv("crm", "crm_contacts.csv"),
    Source.from_csv("billing", "stripe_customers.csv"),
    Source.from_csv("support", "zendesk_users.csv"),
]

# Reconcile
result = reconcile(sources, spec)

# Summary
print(f"\nRecords: {result.total_records}")
print(f"Clusters: {len(result.clusters)}")
print(f"Golden records: {len(result.golden_records)}")
print(f"Merge rate: {result.merge_rate:.1%}")

# Multi-record clusters
print("\nMatched clusters:")
for cluster in result.clusters:
    if len(cluster.members) > 1:
        names = [m.fields.get("name", "?") for m in cluster.members]
        print(f"  {cluster.id}: {', '.join(names)}")

# DataFrame export
df = result.to_pandas()
print(f"\nGolden records DataFrame:\n{df.to_string(index=False)}")

Next Steps

The identity and delegation layer for AI agents.