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:
- Create three CSV data sources with intentionally overlapping records
- Write a matching spec with exact email and fuzzy name rules
- Validate the spec locally — no server required
- Run reconciliation and inspect clusters
- Extract golden records as a pandas DataFrame
- 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
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 Corpstripe_customers.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],1357zendesk_users.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 CortexSave 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@vss.connor@)
These variations are exactly what identity resolution is designed to handle.
Step 2: Write the Spec
Create a file called customer-360.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.6The 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 theattributesmap. - 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
defaultstrategy issource_priority, with per-fieldoverridesthat 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.
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: 2If 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.
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 recordsThe 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.
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.
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.
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:
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 1Step 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:
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.5Also lower the match threshold slightly to allow the combined name + phone signal to produce matches:
decision:
thresholds:
match: 0.80
review: 0.5Re-run reconciliation:
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:
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
- Payment Deduplication — Learn range-based matching for amounts and dates
- Lead Matching — Explore the review queue and most-complete survivorship
- Spec Reference — Full reference for all spec options
- SDK Reference — Complete Python SDK API documentation
