De-identifying HIPAA Geotagged Health Records
HIPAA Safe Harbor requires every geographic subdivision below the state level to be reduced to the first three digits of the ZIP code — and forces those three digits to 000 whenever the prefix covers 20,000 people or fewer — while the Expert Determination pathway lets you retain finer geography if you can prove the residual re-identification risk is very small.
Geotagged records — an emergency department visit with a GPS fix, a home-health encounter, a wearable’s location trace — carry protected health information (PHI) precisely because the where is often as identifying as a name. A home address plus a diagnosis date narrows a population to one person far faster than a name does, which is why HIPAA treats sub-state geography as an identifier in its own right. Under the compliance mapping for GDPR/CCPA location data, HIPAA is the strictest US regime for exactly this coupling of health status and place.
The rule gives you two doors. Safe Harbor is a deterministic checklist that any engineer can apply without a statistician, at the cost of coarse geography. Expert Determination keeps finer geography but requires a documented, defensible risk analysis. The diagram traces the decision.
Core Calculation permalink
Safe Harbor’s geographic rule is deterministic, not probabilistic. It rests on a single population test applied to each three-digit ZIP prefix (ZIP3):
where is the total population of the geographic unit formed by all five-digit ZIPs sharing that prefix. The threshold is a bright line: exactly 20,000 or below fails.
Expert Determination replaces this bright line with a risk bound. If the finest published geography places each individual in a crowd of at least indistinguishable people, the probability that a specific record is correctly singled out by that geography is:
A commonly cited defensible target for health data is (see the HHS precedent discussed in sector-specific k-anonymity thresholds for location data), giving .
Parameters permalink
| Symbol | Meaning | Safe Harbor value |
|---|---|---|
| Population under a 3-digit prefix | must exceed 20,000 | |
| Anonymity set size (Expert Determination) | typically for health data | |
| coordinate precision | Retained lat/long digits | none — coordinates are identifiers |
| geographic floor | Smallest admissible unit | ZIP3, or 000 if |
Worked numeric example permalink
A clinic-visit dataset holds 4 records:
| Patient | ZIP5 | ZIP3 | Safe Harbor geography | |
|---|---|---|---|---|
| A | 90210 | 902 | 1,240,000 | 902 |
| B | 59001 | 590 | 18,300 | 000 (below threshold) |
| C | 33101 | 331 | 2,010,000 | 331 |
| D | 06390 | 063 | 12,400 | 000 (below threshold) |
Patients B and D live under sparse prefixes, so their ZIP3 is zeroed. If instead we pursued Expert Determination and published census tracts, we would compute the anonymity set of each tract-plus-diagnosis combination and require ; a rare-diagnosis patient in a low-population tract would fall below that and would need the tract generalized or suppressed.
Python Implementation permalink
The function below truncates ZIP codes to ZIP3, applies the 20,000-population rule from a supplied population table, drops raw coordinates to an admissible grid, and flags any record that still violates 45 CFR 164.514(b). It assumes WGS84 (EPSG:4326) input coordinates.
from __future__ import annotations
import geopandas as gpd
import numpy as np
import pandas as pd
# Under 45 CFR 164.514(b)(2)(i)(B), a 3-digit ZIP prefix whose combined
# population is <= 20,000 must be reported as "000".
SAFE_HARBOR_POP_THRESHOLD = 20_000
def deidentify_geotagged_records(
records: gpd.GeoDataFrame,
zip_col: str,
zip3_population: dict[str, int],
grid_size_deg: float = 0.1,
) -> gpd.GeoDataFrame:
"""De-identify geotagged health records under HIPAA Safe Harbor geography.
Reduces geography below the state level to a 3-digit ZIP prefix, applies
the <=20,000-population exception, and coarsens raw coordinates so that no
record carries a household-resolving location (a direct identifier).
Args:
records: GeoDataFrame in EPSG:4326 with a point geometry column and a
5-digit ZIP column. Each row is one PHI-bearing encounter.
zip_col: Name of the 5-digit ZIP code column (string dtype).
zip3_population: Mapping of 3-digit ZIP prefix -> total population,
drawn from the Census source named in your governance policy.
grid_size_deg: Cell size (degrees) for coordinate generalization.
0.1 deg ~ 11 km latitude; coarse enough that a point no longer
resolves to a building.
Returns:
A GeoDataFrame with Safe-Harbor geography columns and a boolean
`sh_violation` flag; raw coordinates are removed, not merely hidden.
"""
if records.crs is None or records.crs.to_epsg() != 4326:
raise ValueError("records must be in EPSG:4326 (WGS84).")
out = records.copy()
# Step 1 — reduce geography below the state level to ZIP3.
zip5 = out[zip_col].astype(str).str.zfill(5)
out["zip3"] = zip5.str[:3]
# Step 2 — apply the 20,000-population exception. Unknown prefixes are
# treated as sparse (fail-closed) so missing data never leaks geography.
pop = out["zip3"].map(zip3_population)
below_threshold = pop.isna() | (pop <= SAFE_HARBOR_POP_THRESHOLD)
out["zip3_safe_harbor"] = np.where(below_threshold, "000", out["zip3"])
# Step 3 — coarsen coordinates to an admissible unit. Full-precision
# lat/long is itself a sub-state geographic identifier, so we snap each
# point to a grid centroid and drop the exact geometry entirely.
lon = out.geometry.x
lat = out.geometry.y
out["grid_lon"] = (np.floor(lon / grid_size_deg) + 0.5) * grid_size_deg
out["grid_lat"] = (np.floor(lat / grid_size_deg) + 0.5) * grid_size_deg
out = out.drop(columns="geometry")
# Step 4 — flag any record whose residual geography still resolves below
# the population floor (e.g. a known-sparse prefix that was not zeroed).
resolved_pop = out["zip3_safe_harbor"].map(zip3_population)
out["sh_violation"] = (out["zip3_safe_harbor"] != "000") & (
resolved_pop.notna() & (resolved_pop <= SAFE_HARBOR_POP_THRESHOLD)
)
return out
The design choices that carry compliance weight: unknown prefixes fail closed to 000, so a gap in the population table can never widen the released geography; the exact geometry is dropped, not blanked, so a downstream join cannot resurrect it; and the sh_violation flag lets an auditor confirm the rule fired everywhere it should. This mirrors the coarsening logic behind grid-based spatial fuzzing and buffer-zone implementation, applied here to satisfy a fixed statutory floor rather than a tunable radius.
Verification permalink
Map each check to the governing clause so the output is audit-ready:
def verify_safe_harbor(out: gpd.GeoDataFrame) -> dict:
"""Confirm Safe Harbor geography constraints, keyed to 45 CFR 164.514(b)."""
checks = {
# (b)(2)(i)(B): no sub-threshold prefix survives.
"no_sub_threshold_prefix": not out["sh_violation"].any(),
# (b)(2)(i)(A)-(B): geography reduced to ZIP3 or 000 only.
"zip3_only": out["zip3_safe_harbor"].str.len().eq(3).all(),
# (b)(2)(i): no raw coordinate columns remain in the release.
"coords_dropped": "geometry" not in out.columns,
}
assert all(checks.values()), f"Safe Harbor verification failed: {checks}"
return checks
no_sub_threshold_prefix→ 45 CFR 164.514(b)(2)(i)(B), the 20,000-population exception.zip3_only→ 45 CFR 164.514(b)(2)(i)(A)/(B), the sub-state geography reduction.coords_dropped→ the general prohibition on retaining identifiers, since a building-level coordinate is one.
For the Expert Determination pathway, replace these boolean gates with the anonymity-set test from k-anonymity grouping for location traces: group by (published geography, diagnosis) and assert every group size is at least .
Edge Cases & Adjustments permalink
- Rare conditions: A rare diagnosis can re-identify a patient even inside a large ZIP3, because the disease itself is the quasi-identifier. Safe Harbor does not require suppressing diagnosis, but Expert Determination does — generalize the diagnosis code or suppress the record when (geography, condition) drops below .
- Small ZIP prefixes near the boundary: A prefix at exactly 20,001 people passes today and may fail next Census. Pin your population source and revision date in the audit trail so the
000decision is reproducible. - Coordinate precision leakage: Truncating displayed decimals is not de-identification — the underlying float still encodes the household. Snap to a grid centroid and drop the original geometry, as above.
- Cross-border and territory ZIPs: Some three-digit prefixes span sparse territories; verify your population table covers Puerto Rico, tribal areas, and military ZIPs rather than defaulting them to a large value. When finer geography is genuinely needed, calibrated noise from differential privacy for location data offers a formal alternative to the fixed ZIP3 floor.
FAQ permalink
Can I keep full latitude and longitude under HIPAA Safe Harbor?
No. Full-precision coordinates are a geographic subdivision smaller than a state and typically resolve to a single building, so they are treated as direct identifiers. Safe Harbor permits geography no finer than the initial three digits of a ZIP code, subject to the population rule. To keep census-tract or jittered coordinates you must move to Expert Determination and quantify the residual risk.
Which three-digit ZIP prefixes must be zeroed out?
Any prefix whose combined population is 20,000 or fewer, under 45 CFR 164.514(b)(2)(i)(B). The Census-derived restricted list historically named 17 such prefixes, but the defensible practice is to recompute against the specific population source your organization commits to in policy and to record its revision date.
How does Expert Determination differ from Safe Harbor for geospatial data?
Safe Harbor is a fixed 18-category checklist applied mechanically. Expert Determination, under 45 CFR 164.514(b)(1), lets a qualified statistician certify a very small re-identification risk using methods such as k-anonymity or differential privacy — letting you retain finer geography as long as the residual risk is documented and defensible.
Related
- Compliance Mapping for GDPR/CCPA Location Data — the regulatory context around HIPAA for location data
- Sector-Specific k-Anonymity Thresholds for Location Data — where the k ≥ 11 health precedent comes from
- k-Anonymity Grouping for Location Traces — anonymity-set testing for Expert Determination
- Spatial Fuzzing & Buffer Zone Implementation — coarsening coordinates below household resolution
- Differential Privacy for Location Data — a formal alternative to fixed geographic floors