HIPAA Safe Harbor for Location Data
The HIPAA Safe Harbor method de-identifies a record by stripping 18 enumerated identifiers, and for location data exactly one of those identifiers does the heavy lifting: the geographic subdivision rule at 45 CFR §164.514(b)(2)(i)(B). It removes every geography smaller than a state — street, city, county, precinct, full ZIP, and any geocode — with a single narrow exception: the first three digits of a ZIP code (ZIP3) may survive, but only if the population of that ZIP3 area exceeds 20,000, and any ZIP3 of 20,000 or fewer people is recoded to 000. This page turns that one clause into deterministic engineering. It lives inside the compliance framework mapping guide and applies the sector-agnostic separation from Core Fundamentals & Architecture for Spatial Privacy; it deliberately does not re-cover the broader pipeline in the sibling guide on mapping HIPAA requirements to geospatial datasets, staying tight on the geographic rule alone.
The uncomfortable consequence for anyone holding coordinates: Safe Harbor has no coordinate provision at all. A latitude/longitude pair is strictly finer than a ZIP3, so under Safe Harbor it is not a thing you truncate — it is a thing you delete. If your analytics genuinely need sub-state resolution, Safe Harbor is the wrong method and you must move to Expert Determination, whose risk math is closely related to the cell-population reasoning in how to calculate spatial k-anonymity thresholds.
Parameter Configuration and Calibration
The geographic rule is unusually crisp for a regulation, so there are only three knobs, and none of them is a matter of taste — each is fixed by the text of §164.514(b)(2).
| Rule element | Fixed value | Engineering meaning |
|---|---|---|
| Sub-state geography | removed | Street, city, county, precinct, ZIP5, and geocodes are all suppressed; only ZIP3 and state survive |
| ZIP3 retention floor | population > 20,000 | Keep the first three ZIP digits only if the combined ZIP3 area exceeds 20,000 residents |
| Recode value | "000" |
Every ZIP3 area of 20,000 or fewer people has its three digits replaced by the literal 000 |
| Population source | current Census Bureau data | The count is per the “currently available” decennial census tabulation, not an internal estimate |
| Raw coordinate | never permitted | Any point is finer than ZIP3, so Safe Harbor deletes it rather than generalising it |
The 20,000 floor is a hard > comparison, not >=: a ZIP3 area with exactly 20,000 residents fails the test and must be recoded to 000. Getting the boundary condition wrong is the single most common Safe Harbor coding defect, because it flips a small set of real ZIP3s from compliant to non-compliant.
That small set is enumerable. Against the Census 2000 tabulation that HHS/OCR published guidance against, seventeen three-digit ZIP prefixes fell at or below the floor and must be recoded: 036, 059, 063, 102, 203, 556, 692, 790, 821, 823, 830, 831, 878, 879, 884, 890, and 893. Treat this list as a cross-check rather than the primary control — the authoritative decision is the population comparison, and you must re-derive the restricted set whenever a new decennial census supersedes the tabulation your pipeline was calibrated against. Hard-coding the seventeen values and never revisiting them is how a pipeline silently drifts out of compliance a decade later.
The population figure itself is the input you must source carefully. It is the count for the aggregate geographic unit formed by combining every five-digit ZIP that shares those first three digits — not the population of any single ZIP5, and not a service-area estimate. Join your records to a ZIP3-level census rollup once, at ingestion, and carry the resulting token as first-class metadata exactly as the parent compliance framework mapping workflow carries every other derived compliance attribute.
Reference Implementation
The module below is the geographic redaction gate and nothing else — no k-anonymity, no noise, no aggregation. It exposes SafeHarborGeo.token(zip5, zip3_population), which returns the only sub-state geography Safe Harbor permits (a ZIP3 string or the literal "000"), and a geographic_token wrapper that refuses to emit or truncate a raw coordinate. The population comparison is the authoritative decision; the published restricted set is a defensive cross-check so a stale population join cannot leak a small ZIP3.
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping
POP_FLOOR: int = 20_000 # 45 CFR 164.514(b)(2)(i)(B): retain ZIP3 only if pop > 20,000
RECODE_TOKEN: str = "000" # every ZIP3 area of 20,000 or fewer is recoded to 000
# ZIP3 prefixes at or below the floor per the Census 2000 tabulation HHS/OCR
# published against. This is a CROSS-CHECK, not the primary rule: re-derive it
# whenever a newer decennial census supersedes the calibrated population table.
RESTRICTED_ZIP3: frozenset[str] = frozenset({
"036", "059", "063", "102", "203", "556", "692", "790",
"821", "823", "830", "831", "878", "879", "884", "890", "893",
})
class RawCoordinateError(ValueError):
"""Raised when a raw lat/lon reaches the Safe Harbor geographic gate."""
@dataclass(frozen=True)
class SafeHarborGeo:
"""Geographic identifier reduction for HIPAA Safe Harbor, 45 CFR 164.514(b)."""
pop_floor: int = POP_FLOOR
def token(self, zip5: str, zip3_population: int) -> str:
"""Return the sole sub-state geography Safe Harbor allows: ZIP3 or '000'.
Rule 45 CFR 164.514(b)(2)(i)(B): the initial three ZIP digits may be
retained ONLY if the geographic unit formed by all ZIP codes sharing
those three digits contains MORE THAN 20,000 people. Any ZIP3 area of
20,000 OR FEWER is recoded to 000. The comparison is a strict '>', so a
ZIP3 of exactly 20,000 residents is recoded, not kept.
"""
if not (isinstance(zip5, str) and zip5.isdigit() and len(zip5) == 5):
raise ValueError(f"zip5 must be a 5-digit string, got {zip5!r}")
if zip3_population < 0:
raise ValueError("zip3_population must be non-negative")
zip3 = zip5[:3]
# Recode if the aggregate ZIP3 population is at or below the floor, OR if
# the prefix is on the published restricted list (belt-and-braces against
# a stale or wrong population figure). Either condition suppresses ZIP3.
if zip3_population <= self.pop_floor or zip3 in RESTRICTED_ZIP3:
return RECODE_TOKEN
return zip3
def geographic_token(record: Mapping[str, object], zip3_population: int) -> str:
"""Emit a Safe Harbor geographic token, refusing any raw coordinate.
Safe Harbor has no provision that admits a latitude/longitude: a point is
strictly finer than ZIP3, so its presence means the record is NOT
de-identified. We reject rather than truncate, because a truncated point is
still a 'geographic subdivision smaller than a state' and never becomes a
ZIP3 by rounding.
"""
if record.get("lat") is not None or record.get("lon") is not None:
raise RawCoordinateError(
"raw coordinate present; Safe Harbor cannot emit or truncate a point "
"— delete the coordinate or switch to Expert Determination"
)
zip5 = record["zip5"]
if not isinstance(zip5, str):
raise ValueError("record['zip5'] must be a 5-digit string")
return SafeHarborGeo().token(zip5, zip3_population)
Validation Checkpoint
The failure mode here is silent under-redaction: a boundary error keeps a small ZIP3, or a coordinate slips through un-noticed, and the release looks fine until an auditor recomputes it. Run these assertions in CI so the > boundary, the 000 recode, the restricted-set cross-check, and the coordinate refusal are all pinned.
def _validate() -> None:
gate = SafeHarborGeo()
# 1. ZIP3 area strictly above the floor keeps its three digits.
assert gate.token("02139", 812_000) == "021" # metro Boston ZIP3, large pop
# 2. Population at or below the floor is recoded to 000.
assert gate.token("59220", 18_500) == "000" # <= 20,000 -> 000
assert gate.token("59220", 20_000) == "000" # EXACTLY 20,000 fails '>' -> 000
assert gate.token("59220", 20_001) == "592" # one over the floor -> keep ZIP3
# 3. Published restricted ZIP3 is recoded even if a stale table over-counts it.
assert gate.token("06390", 999_999) == "000" # '063' is on the restricted list
# 4. A raw coordinate is refused outright, never truncated into a token.
for bad in ({"zip5": "02139", "lat": 42.3601}, {"zip5": "02139", "lon": -71.09}):
try:
geographic_token(bad, 812_000)
except RawCoordinateError:
pass
else:
raise AssertionError("raw coordinate must be rejected, not emitted")
# 5. A coordinate-free record resolves to the population-driven token.
assert geographic_token({"zip5": "94103", "lat": None}, 640_000) == "941"
# 6. Malformed ZIP inputs fail loudly rather than emitting a partial token.
for bad_zip in ("941", "9410X", 94103):
try:
gate.token(bad_zip, 640_000) # type: ignore[arg-type]
except ValueError:
pass
else:
raise AssertionError("malformed zip5 must raise")
print("Safe Harbor geographic gate: all assertions passed")
if __name__ == "__main__":
_validate()
Incident Response and Edge Cases
-
A truncated coordinate mistaken for a ZIP3 substitute. An upstream team “de-identifies” by rounding lat/lon to three decimals (~110 m) and believes that satisfies the ZIP3 rule. It does not: ~110 m is still a geographic subdivision far smaller than a state, and Safe Harbor recognises no coordinate at any precision. Remediation: route every record through
geographic_token, which raisesRawCoordinateErroron any survivinglat/lon, and drop the coordinate columns before the gate rather than rounding them. -
The 20,000-exactly boundary flips a ZIP3. A census refresh moves a ZIP3 area to precisely 20,000 residents. Code using
>=keeps it; the rule requires>, so it must recode to000. Remediation: assert the strict comparison in a regression test (item 2 of the harness) and re-run the full corpus after any population-table update, logging every ZIP3 whose token changed. -
Stale restricted-set drift. The seventeen hard-coded prefixes are pinned to one decennial tabulation. A later census can push a previously large ZIP3 below 20,000 (or vice versa), so a frozen list silently mis-classifies it. Remediation: treat the population comparison as authoritative, regenerate
RESTRICTED_ZIP3from the current census whenever it is published, and diff the new set against the old before deploying. -
Analytics genuinely need sub-state resolution. When a study cannot function with ZIP3-or-
000granularity — say it models intra-city mobility — Safe Harbor is structurally the wrong method, because there is no compliant way to release a finer geography under it. Remediation: switch to Expert Determination, where a qualified statistician certifies a “very low” residual re-identification risk for a specific finer release, using the cell-population and k-anonymity reasoning developed in how to calculate spatial k-anonymity thresholds and the full de-identification pipeline in mapping HIPAA requirements to geospatial datasets. Do not stretch Safe Harbor to fit; it has no dial.
Frequently Asked Questions
Why can't I keep a truncated latitude/longitude under Safe Harbor?
Because Safe Harbor's geographic rule recognises no coordinate at any precision. Its only permitted sub-state geography is the first three ZIP digits, and only when the ZIP3 area exceeds 20,000 people. A latitude/longitude rounded to three or even one decimal place is still a geographic subdivision smaller than a state, so it is removed outright, not generalised. Rounding a point never turns it into a ZIP3; the compliant move is to delete the coordinate or switch to Expert Determination.
What exactly happens to a ZIP3 area with 20,000 or fewer residents?
Its three digits are replaced with the literal token 000. The rule keeps the first three ZIP digits only when the geographic unit formed by combining all ZIP codes sharing those digits contains more than 20,000 people. The comparison is a strict greater-than, so a ZIP3 area of exactly 20,000 residents fails the test and is recoded to 000, just like any smaller one.
Which ZIP3 areas are on the restricted 000 list?
Against the Census 2000 tabulation that HHS published guidance against, seventeen ZIP3 prefixes fell at or below the 20,000 floor: 036, 059, 063, 102, 203, 556, 692, 790, 821, 823, 830, 831, 878, 879, 884, 890, and 893. Treat that list as a cross-check, not the authoritative rule. The population comparison decides each case, and the restricted set must be re-derived whenever a newer decennial census supersedes the table your pipeline was calibrated against.
When should I abandon Safe Harbor for Expert Determination?
Whenever your analytics require geography finer than ZIP3 or 000. Safe Harbor is a fixed checklist with no tunable resolution: it can only ever emit a large-ZIP3 prefix, 000, or a state. Expert Determination instead lets a qualified statistician certify that a specific finer release carries a very low re-identification risk, which is where cell-population floors, k-anonymity thresholds, and noise mechanisms come back into play. If you find yourself wanting to bend the ZIP3 rule, that is the signal to change methods rather than parameters.
Related
- Up: Compliance Framework Mapping · Core Fundamentals & Architecture for Spatial Privacy
- Mapping HIPAA Requirements to Geospatial Datasets — the full de-identification pipeline this geographic rule plugs into.
- How to Calculate Spatial K-Anonymity Thresholds — the population-floor math behind Expert Determination when Safe Harbor is too coarse.
Up: Compliance Framework Mapping · Core Fundamentals & Architecture for Spatial Privacy