dedupe_df() into a durable, queryable identity graph. Stable entity_ids survive re-runs, every match has provenance, and the same answer comes back from Python, SQL, REST, MCP, A2A, and the web UI.
Shipped in v1.15.0 (2026-05-12). Off by default: the zero-config posture is preserved. Enable via
config.identity.enabled = True or an identity: block in YAML.What problem this solves
A plainrun_dedupe() returns clusters whose IDs are meaningful only inside that result. Re-run the pipeline tomorrow with one new record and:
- Cluster IDs are different.
- There is no record of “Alice Smith merged into entity 7 because of evidence X.”
- A second source pointing at the same person has no way to find the existing identity.
- The web/SQL/agent surfaces all reason from scratch each time.
Quickstart
Add anidentity: block to your config and run dedupe normally:
Storage model
Five tables. SQLite default at.goldenmatch/identity.db; Postgres optional.
Postgres ships three analytical views:
v_identities, v_identity_pairs, v_identity_timeline. Apply via packages/python/goldenmatch/goldenmatch/db/migrations/identity_v1.sql or let IdentityStore(backend="postgres", connection=...) create them on first connect.
How resolution works
After clustering, the pipeline takes each cluster and:- Look up existing identities that already own any record in the cluster.
- Decide what happened:
- No overlap, mint a new identity (UUIDv7), emit
created. - One existing identity covers all overlapping records, absorb the new records, emit
absorbed_recordper addition. - Multiple existing identities overlap, merge them. Winner = most members (tie-break: oldest
created_at). Emitmerged_withon winner, retire losers withstatus='merged_into', merged_into=<winner>.
- No overlap, mint a new identity (UUIDv7), emit
- Upsert every cluster record under the chosen identity.
- Record evidence, one row in
evidence_edgesfor every scored within-cluster pair, including matchkey name, per-field scores, negative-evidence penalties, and a controller-telemetry snapshot.
entity_id is stable across runs. The same Alice Smith on a Tuesday run and a Wednesday run with one extra record both resolve to the same UUID, evidence in evidence_edges shows which run added which edge.
Resolution is idempotent: replaying the same run_name is a no-op. Edges deduplicate on (entity_id, record_a_id, record_b_id, run_name). Events deduplicate on (run_name, kind, entity_id).
Surfaces: one shape, many faces
Every surface returns the same JSON (theIdentityView.to_dict() shape). The cross-surface contract test at tests/identity/test_cross_surface_contract.py enforces this byte-for-byte across all six.
Python
CLI
REST
Web UI
The “Identities” tab ingoldenmatch serve-ui lists identities with dataset/status filters, drills into one to show members + evidence + event log, and supports steward merge/split.
MCP
Fifteen tools on the standard MCP server (goldenmatch mcp-serve):
identity_resolve: look up byrecord_ididentity_show: full payload byentity_ididentity_list: list with filtersidentity_history: event logidentity_conflicts: listconflicts_withedgesidentity_merge/identity_split: steward operationsidentity_claim: assign a record to a durable identityidentity_resolve_conflict: resolve a flaggedconflicts_withedgeidentity_profile/identity_stats/identity_worklist: inspection + steward triageidentity_audit/identity_audit_seal/identity_audit_verify: tamper-evident audit log
A2A
Twelve of these are also A2A skills on the agent server (goldenmatch agent-serve); identity_profile / identity_stats / identity_worklist are MCP-only. The agent card declares 38 total skills.
SQL (Postgres + DuckDB)
Thegoldenmatch-duckdb PyPI package (>= 0.3.0) and goldenmatch_pg Postgres extension (>= 0.4.0) expose five read-only functions per backend:
IdentityView.to_dict() returns. SQL is read-only, writes go through the Python CLI, REST endpoints, or MCP tools.
”Why did these link?”: reading the evidence
Every link decision is auditable. Pull an entity’s edges:GET /api/v1/identities/{eid}/evidence, identity_history over MCP/A2A, and the DuckDB / Postgres _view functions.
Configuration reference
source_pk_column is unset and you have near-duplicate raw rows from the same source, two physically-different observations may collide on the same record_id. The recommended pattern is to always pass an explicit PK column when you can.
Postgres setup
Apply the schema directly (skip if you only use SQLite):v_identities, v_identity_pairs, v_identity_timeline) that the bare IdentityStore does not. Prefer the migration file for shared/team setups.
Performance notes
- Resolve runs after clustering, before output. It is dominated by write throughput, and its cost tracks the identity graph, not the input frame. Roughly the number of identities created plus records upserted. A 1M-row dedupe that yields 50k multi-record identities does far less resolve work than a 200k-row dedupe that yields 200k.
-
emit_singletonsis the single biggest cost lever, and it defaults totrue. With it on, every single-record cluster becomes its own identity, so resolve work scales with row count rather than with the number of real multi-record entities: on a 14M-row dedupe that is 14M identities, not ~500k. Turn it off unless you specifically need a durable id for records that matched nothing:Withemit_singletons: falsethe resolver also skips preparing rows no multi-record cluster references, so both wall time and resident memory drop with it. Concrete ceiling. On the SQLite backend,emit_singletons: truebecomes impractical around ~100k rows. Every row is prepped and written one statement at a time, so resolve tracks the whole input frame. Measured on a single box: 250k rows with singletons on is already slow, whileemit_singletons: falseresolves a 1M-row dedupe in ~6 minutes because it only touches the multi-record identity graph. Above ~100k rows with singletons on, the resolver logs a one-time advisory pointing atemit_singletons: falseand Postgres. (The advisory is SQLite-only and never blocks the run; Postgres’s bulkCOPYwrite path makes the singleton row count a non-issue there.) -
SQLite ceiling. SQLite is the right default through the low millions of rows on a single box. Past that, prefer Postgres: brand-new identities take a bulk
COPYfast path there, and it is the only backend the distributed (Ray) resolver supports. The tradeoff is network latency per run, not a throughput cliff. -
The per-partition (distributed) resolver is not reachable from a single-node in-memory run.
resolve_identities_distributedneeds both Ray andbackend: postgres, it materializes cluster aggregates driver-side and resolves them against a pooled Postgres connection (true per-partitionmap_batchesis still on the roadmap; see the Phase 6 notes). A plain single-node run on SQLite therefore has exactly two scale levers:emit_singletons: false, and, once you are past the low millions of rows, moving the store to Postgres. Adding Ray without a Postgres store does not change the single-node path. The SQLite gap is in our write path, not in SQLite: the stdlibsqlite3driver has noCOPYequivalent, so resolve writes go row-at-a-time (batched into transactions) rather than as columnar bulk ingest. Arrow-native bulk ingest into SQLite is available, the Apache Arrow ADBC SQLite driver supportsadbc_ingestfrom Python and can be driven from Rust viaadbc_core+adbc_driver_manager, and adopting it would let the SQLite backend share the same staging-table-then-upsert shape the Postgres bulk path uses. That is a planned follow-up, not something GoldenMatch does today. - Resolution is gated and additive: if the store fails to open, the pipeline logs a warning and continues. Identity never blocks a dedupe.
-
For multi-process writers, the SQLite store uses WAL + a 5s
busy_timeout. Postgres relies on row-level locks. Single-tenant web UI / CLI invocations are the assumed model; for high-write multi-tenant graphs use Postgres.
When NOT to use it
- Single-shot ad-hoc dedupe where you only want golden records out and don’t care about the next run.
- Pipelines whose source has no stable PK and whose rows are duplicated character-for-character, the hash fallback will fold them together.
Migration / backfill
Existing projects without an identity graph don’t get retroactiveentity_id stability. New runs will assign fresh UUIDs from the moment you enable identity. A best-effort backfill command that walks lineage JSONL + cluster snapshots is a planned follow-up and is not yet available; today you enable identity and let stability accrue from the first resolved run forward.
Migrating legacy record ids
Whensource_pk_column is unset, GoldenMatch originally keyed records with a JSON-hash fingerprint: {source}:hash:{12 hex}. Starting in v1.26, the canonical scheme is {source}:h1:{12 hex}. A stable, cross-surface fingerprint computed by the goldenmatch-fingerprint-core kernel (the same hash used in Python, Rust, SQL, and WASM surfaces).
1.x back-compat (removed in 2.0): through the 1.x series the store resolved legacy :hash: ids by trying the canonical :h1: lookup first and falling back to :hash: automatically, emitting a once-per-process deprecation warning when the fallback fired.
Removed in GoldenMatch 2.0: the dual-candidate fallback and the GOLDENMATCH_IDENTITY_ID_SCHEME=hash kill-switch are gone. Fingerprintable rows now resolve to a single :h1: candidate, a store still holding :hash:-keyed records from a fingerprintable source will no longer match them, and those clusters will split on the next run. Un-fingerprintable rows (where no stable fingerprint can be derived) keep their :hash: id as their only key. If you persist an identity DB, run the migration below BEFORE upgrading to 2.0.
Running the migration
{source}:hash:{12} record id in source_records, evidence_edges, identity_aliases, and identity_events to the canonical {source}:h1:{12} form. It reports the number of rows rewritten per table. On a large store, run during a maintenance window. The rewrite takes an exclusive table lock for the duration.
Python API
See also
examples/python/08_identity_graph.py: end-to-end demo (two-run stability + absorb + merge + split + conflict)- Pipeline architecture: where identity sits in the dedupe flow
- Learning Memory: the other persistent-state layer; complementary, not competing
- Configuration: full schema reference