Building a Deterministic Patient Match Key in Python

A deterministic match key is the high-precision first pass of a Master Patient Index: before any probabilistic scoring runs, you normalize a handful of demographic fields into one canonical string and treat two records that share it as the same person. This page is the runnable companion to the patient matching and MPI strategies guide, and it focuses on the one thing that pass lives or dies by: constructing a reproducible composite key so that SMITH|John|19800115|M and smith, john born the same day collapse to an identical value while genuinely different people never do.

The deterministic pass is not a replacement for probabilistic matching with Fellegi–Sunter; it is the cheap, exact-match layer that clears the easy majority of links so the expensive scorer only sees the residual. Its correctness depends entirely on normalization being deterministic — the same input person must always produce byte-identical output, regardless of source system casing, punctuation, or encoding.

Field normalization quick reference

Every field that feeds the composite key needs one unambiguous rule. The table below is the specification to keep next to your MPI loader — apply these rules in exactly this form on both sides of every comparison, or the key stops being deterministic.

Field Normalization rule John A. Smith-Jones
Surname Uppercase, strip punctuation and whitespace, transliterate accents to ASCII SMITHJONES
Given name First whitespace token only, uppercase, ASCII JOHN
Date of birth ISO basic YYYYMMDD, zero-padded, no separators 19800115
Sex Single uppercase administrative char (M/F/U) M
SSN Digits only, all non-digits stripped; optional and never required 123456789

The surname carries the most weight, so it gets the most aggressive normalization: hyphenated and multi-part family names are concatenated after punctuation removal, and accented characters are transliterated (MuñozMUNOZ) so the same person entered on a Unicode-aware and a Latin-1 system still keys identically. Given name deliberately keeps only the first token — middle names, initials, and suffixes are unreliable across encounters and would otherwise split records.

Deterministic match key construction pipeline A data-flow diagram. Raw demographic fields — surname, given name, date of birth, sex, and optional SSN — enter per-field normalizers. The normalized fields fan out to two derived keys: a phonetic blocking key built from a Soundex code of the surname plus the birth year, which constrains which records are compared, and a canonical composite key that concatenates the normalized fields with a pipe delimiter. The composite key is then passed through a salted SHA-256 hash for safe storage and lookup. Fields → normalize → blocking key + composite key → hash Raw fields surname given DOB sex SSN (optional) Per-field normalizers upper · strip · ASCII Blocking key Soundex(surname) + birth year Composite key SURNAME|GIVEN| DOB|SEX|SSN Salted hash SHA-256 store / lookup
Normalized fields fan out into a phonetic blocking key (which limits comparisons) and a canonical composite key (which is then salted-hashed for storage).

Implementation pattern

The implementation has three layers: per-field normalizers, a phonetic blocking key that keeps comparisons within manageable blocks, and the canonical composite key with an optional salted hash for storage. Start with the normalizers — each is a pure function so the whole key is trivially testable.

import hashlib
import hmac
import re
import unicodedata
from datetime import date


def _to_ascii(value: str) -> str:
    """Transliterate accented characters to plain ASCII (Muñoz -> Munoz)."""
    decomposed = unicodedata.normalize("NFKD", value)
    return decomposed.encode("ascii", "ignore").decode("ascii")


def norm_surname(raw: str) -> str:
    """Uppercase, strip punctuation/whitespace, transliterate accents."""
    ascii_only = _to_ascii(raw or "")
    letters = re.sub(r"[^A-Za-z]", "", ascii_only)
    return letters.upper()


def norm_given(raw: str) -> str:
    """First whitespace token only, uppercased ASCII letters."""
    ascii_only = _to_ascii(raw or "").strip()
    first = ascii_only.split()[0] if ascii_only.split() else ""
    return re.sub(r"[^A-Za-z]", "", first).upper()


def norm_dob(raw: date) -> str:
    """ISO basic format YYYYMMDD; raises on a non-date so bad input never keys."""
    return raw.strftime("%Y%m%d")


def norm_sex(raw: str) -> str:
    """Single administrative char; anything unmapped becomes U (unknown)."""
    return {"M": "M", "F": "F"}.get((raw or "").strip().upper()[:1], "U")


def norm_ssn(raw: str) -> str:
    """Digits only; empty string when absent (SSN is optional, never required)."""
    return re.sub(r"\D", "", raw or "")

Next, the phonetic blocking key. Blocking exists to avoid an O(n²) all-pairs comparison: instead of comparing every record to every other, you only compare records that share a coarse phonetic block. Soundex on the surname plus the four-digit birth year is a robust default — it groups SMITH, SMYTH, and SMITHE together while a full name typo does not scatter them across blocks. (NYSIIS is a stronger alternative if your population has many non-Anglo surnames; swap the encoder without changing the surrounding logic.)

def soundex(surname: str) -> str:
    """Classic Soundex: keep the first letter, encode the rest, pad to 4 chars."""
    surname = norm_surname(surname)
    if not surname:
        return "0000"
    codes = {**dict.fromkeys("BFPV", "1"), **dict.fromkeys("CGJKQSXZ", "2"),
             **dict.fromkeys("DT", "3"), "L": "4",
             **dict.fromkeys("MN", "5"), "R": "6"}
    first, encoded, prev = surname[0], "", codes.get(surname[0], "")
    for char in surname[1:]:
        digit = codes.get(char, "")
        if digit and digit != prev:
            encoded += digit
        if char not in "HW":
            prev = digit
    return (first + encoded + "000")[:4]


def blocking_key(surname: str, dob: date) -> str:
    """Phonetic block that constrains which records are ever compared."""
    return f"{soundex(surname)}-{dob.year}"

Finally, assemble the canonical composite key and its salted hash. The composite key is a pipe-delimited string in a fixed field order; the delimiter must be a character that cannot survive normalization (a pipe never appears in a normalized field) so the boundaries are unambiguous. The hash uses HMAC-SHA-256 with a secret salt loaded from your secrets manager — it lets you store and index keys without holding the raw demographic string, but see the compliance note below on why that is not de-identification.

SALT = b"load-from-secrets-manager-never-hardcode"  # per-deployment secret


def composite_key(surname, given, dob, sex, ssn=""):
    """Canonical deterministic key; identical input people -> identical string."""
    return "|".join([
        norm_surname(surname),
        norm_given(given),
        norm_dob(dob),
        norm_sex(sex),
        norm_ssn(ssn),
    ])


def hashed_key(key: str) -> str:
    """Salted HMAC-SHA-256 of the composite key for safe storage/lookup."""
    return hmac.new(SALT, key.encode("utf-8"), hashlib.sha256).hexdigest()


record = {"surname": "Smith-Jones", "given": "John A.",
          "dob": date(1980, 1, 15), "sex": "m", "ssn": "123-45-6789"}
key = composite_key(record["surname"], record["given"], record["dob"],
                    record["sex"], record["ssn"])
# key    -> 'SMITHJONES|JOHN|19800115|M|123456789'
# block  -> blocking_key(record["surname"], record["dob"]) == 'S532-1980'
# stored -> hashed_key(key)  (64-char hex, no raw demographics)

Within the deterministic pass, two records match when their composite keys are equal; blocking guarantees you only ever compare keys inside the same phonetic block, and the coercion of source values into these canonical types is the same discipline covered in type coercion for clinical data types.

Validation and testing

The property that makes a key deterministic is invariance to cosmetic input differences and sensitivity to real ones. Test both directions explicitly: casing, punctuation, and accent variants of the same person must produce identical keys, and a transposed date of birth must not.

from datetime import date


def test_cosmetic_variants_produce_identical_keys():
    a = composite_key("Smith-Jones", "John A.", date(1980, 1, 15), "M", "123-45-6789")
    b = composite_key("smith, jones", "john", date(1980, 1, 15), "m", "123456789")
    assert a == b == "SMITHJONES|JOHN|19800115|M|123456789"


def test_transliteration_is_stable():
    assert composite_key("Muñoz", "José", date(1975, 3, 9), "M") == \
           composite_key("Munoz", "Jose", date(1975, 3, 9), "M")


def test_transposed_dob_does_not_match():
    correct = composite_key("Nguyen", "Anh", date(1990, 11, 12), "F")
    typo    = composite_key("Nguyen", "Anh", date(1990, 12, 11), "F")
    assert correct != typo


def test_blocking_groups_phonetic_variants():
    assert blocking_key("Smith", date(1980, 1, 1)) == \
           blocking_key("Smyth", date(1980, 6, 30))


def test_hash_is_stable_and_opaque():
    key = composite_key("Smith", "John", date(1980, 1, 15), "M")
    assert hashed_key(key) == hashed_key(key)   # deterministic
    assert "SMITH" not in hashed_key(key)       # no raw demographics leak

Run these as part of CI on every change to a normalizer. The test_transposed_dob_does_not_match case is the important guardrail: it proves the key is discriminating enough that a real difference survives, which is exactly what stops a deterministic pass from over-merging distinct patients.

Gotchas and compliance constraints

  • Over-tight keys fragment records. Every field you add to the composite key raises precision but lowers recall: including middle name, full SSN, or a mutable field like address means one legitimately-changed or mistyped value produces a brand-new key and a split record. The deterministic pass should key on the most stable fields only and hand ambiguous cases to the probabilistic scorer rather than forcing an exact match. A blocking key that is too coarse has the opposite failure — comparisons blow up — so tune block size against your population.

  • SSN is neither reliably present nor unique. Treat SSN as optional: it is missing on pediatric, neonatal, and non-resident records, it is shared within families on some legacy feeds, and recycled or invalid values appear regularly. Never make SSN a required key component, never assume equality of SSN implies same-person, and normalize to digits-only before any comparison. Culture-specific name ordering compounds this — surname/given order is not universal, and a system that stores a patronymic or a Spanish two-surname name in the wrong field will key a real person incorrectly.

  • Hashing the key does not remove PHI obligations. A salted hash makes the stored key opaque to a casual reader, but the key is derived from demographic identifiers and remains re-identifiable given the salt, so the hash store is still Protected Health Information under HIPAA. It must live inside the same access-controlled, audited, encrypted boundary as the raw data; the salt is a secret whose disclosure re-exposes every key. Hashing is a storage-hygiene measure, not de-identification under the Safe Harbor or Expert Determination methods.