Caching FHIR $validate-code Lookups in Redis
Terminology operations — $validate-code, $lookup, $translate — are the hidden tax on clinical ETL throughput: each one is a network round-trip to a terminology server, and at ingestion scale those round-trips dominate end-to-end latency long before your mapping logic does. A Redis cache in front of the terminology server removes that tax, but only if the cache key captures the full validity semantics of the operation; get the key wrong and you trade latency for a far worse problem — serving a stale “valid” verdict for a code that a later terminology release retired. This page is the runnable caching companion to FHIR terminology server integration: where that guide covers wiring the server into the pipeline, here we make its calls fast without ever caching a wrong answer.
The reason terminology calls cannot be cached with a naive code-only key is that validity is not a property of a code in isolation. A code is valid with respect to a value set, as evaluated under a specific code-system version. SNOMED CT publishes twice a year; LOINC publishes roughly twice a year; a code that is active in one release can be retired in the next, which flips a $validate-code result from true to false with no change to the code text itself. If your key does not encode the version, your cache will happily return last release’s verdict forever.
Cache key components (quick reference)
The key must be a complete, order-stable fingerprint of everything the terminology server used to decide the answer. Omit any component the server considered and the cache can return a verdict computed for a different question. The table below is the lookup artifact to keep next to your cache wrapper.
| Key component | Why it is required |
|---|---|
system |
The code system URI (e.g. http://snomed.info/sct, http://loinc.org) — the same code string means different things in different systems. |
code |
The code being validated; the literal token whose membership is in question. |
valueSetUrl |
Validity is membership in a value set; the same code can be valid in one binding and invalid in another. |
codeSystemVersion |
The classic correctness bug when omitted — a code’s active/retired status changes across terminology releases, so the answer is version-scoped. |
displayLanguage |
Only if you request or validate the display; the server returns language-specific designations, so a shared key would leak the wrong language. |
| version prefix | Not part of the semantic identity but prepended for bulk invalidation — bump it to expire an entire release’s entries at once. |
Two components are frequently dropped and each is a latent bug. Dropping valueSetUrl conflates “is this a real SNOMED code” with “is this code allowed in us-core-condition-code” — different questions with different answers. Dropping codeSystemVersion is the more dangerous one because it fails silently: the pipeline keeps returning cached verdicts that were correct at cache-write time and are wrong after the next terminology publish.
Implementation pattern: a redis-py cache wrapper
The wrapper below sits between the ETL and the terminology client. It builds the composite key, does a GET, and on a miss calls the server and writes the result back with SETEX. Crucially it caches both outcomes — a true verdict and a false verdict — because negative results are network round-trips too, and a malformed feed can hammer the server with the same invalid code millions of times. Negative entries get a shorter TTL so a code that becomes valid in a new release is re-checked sooner.
import json
import hashlib
from dataclasses import dataclass
from typing import Optional
import redis
@dataclass(frozen=True)
class ValidateCodeRequest:
"""Everything the terminology server uses to decide validity."""
system: str # e.g. "http://snomed.info/sct"
code: str # the code under test
value_set_url: str # membership is evaluated against THIS value set
code_system_version: str # release-scoped; omitting this is the classic bug
display_language: Optional[str] = None # only if display is validated
class ValidateCodeCache:
"""Redis-backed cache for $validate-code with version-scoped invalidation."""
# Version prefix lets us invalidate an entire terminology release at once
# by bumping it — old keys are simply never read again and age out via TTL.
PREFIX_KEY = "tx:vc:prefix" # holds the current integer prefix
POSITIVE_TTL = 60 * 60 * 24 * 30 # 30 days: valid verdicts are stable
NEGATIVE_TTL = 60 * 60 * 6 # 6 hours: re-check 'invalid' sooner
def __init__(self, client: redis.Redis, tx_client):
self.r = client
self.tx = tx_client # real $validate-code client (network)
def _prefix(self) -> str:
# Default to "1" the first time; bumping this key invalidates everything.
return self.r.get(self.PREFIX_KEY) or "1"
def _key(self, req: ValidateCodeRequest) -> str:
# The key MUST contain every semantic component. We hash the composite
# to bound key length while keeping it fully collision-resistant.
semantic = "|".join([
req.system,
req.code,
req.value_set_url,
req.code_system_version,
req.display_language or "",
])
digest = hashlib.sha256(semantic.encode("utf-8")).hexdigest()
return f"tx:vc:{self._prefix()}:{digest}"
The key is a SHA-256 of the pipe-joined semantic tuple, namespaced under the current version prefix. Hashing keeps keys short and safe (raw code-system URIs and value-set URLs contain characters you would otherwise have to escape) while preserving one-to-one identity with the request. Note what is not in the key: no patient identifier, no encounter, no free text — a code and its binding are reference data, and nothing patient-specific belongs in a shared terminology cache.
def validate_code(self, req: ValidateCodeRequest) -> bool:
key = self._key(req)
# 1. Fast path: cache hit returns the stored verdict with no server call.
cached = self.r.get(key)
if cached is not None:
return json.loads(cached)["result"]
# 2. Miss: call the terminology server exactly once for this key.
result = self.tx.validate_code(
system=req.system,
code=req.code,
value_set_url=req.value_set_url,
system_version=req.code_system_version,
display_language=req.display_language,
) # -> bool
# 3. Write-through. Cache BOTH outcomes; negatives expire sooner.
ttl = self.POSITIVE_TTL if result else self.NEGATIVE_TTL
self.r.setex(key, ttl, json.dumps({"result": result}))
return result
def invalidate_release(self) -> int:
"""Bump the version prefix to invalidate every cached entry at once.
O(1) and non-blocking: no SCAN, no mass DEL. Old keys become
unreachable and age out under their own TTLs. Call this after a
SNOMED/LOINC release or a value-set expansion refresh.
"""
return self.r.incr(self.PREFIX_KEY)
invalidate_release is the operational payoff of the version prefix. When a new SNOMED CT edition lands, you do not enumerate and delete millions of keys — a SCAN/DEL sweep that blocks Redis and races live traffic. You increment one counter. Every subsequent _key call reads the new prefix, so every lookup misses and re-fetches against the new release, while the orphaned old-prefix keys quietly expire. This is the correct way to bind cache lifetime to terminology release lifetime.
Validation and testing
The two properties that must hold are: an identical second lookup is served from cache with no server call, and bumping the version prefix forces a re-fetch. A fake terminology client that counts calls proves both cheaply.
class CountingTxClient:
"""Test double: records how many times the network path was hit."""
def __init__(self, result: bool):
self.result = result
self.calls = 0
def validate_code(self, **kwargs) -> bool:
self.calls += 1
return self.result
def test_second_lookup_hits_cache():
import fakeredis
tx = CountingTxClient(result=True)
cache = ValidateCodeCache(fakeredis.FakeRedis(), tx)
req = ValidateCodeRequest(
system="http://snomed.info/sct",
code="386661006", # Fever (finding)
value_set_url="http://hl7.org/fhir/us/core/ValueSet/us-core-condition-code",
code_system_version="http://snomed.info/sct/731000124108/version/20240301",
)
assert cache.validate_code(req) is True
assert cache.validate_code(req) is True # identical lookup
assert tx.calls == 1 # server called only ONCE
def test_prefix_bump_forces_refetch():
import fakeredis
tx = CountingTxClient(result=True)
cache = ValidateCodeCache(fakeredis.FakeRedis(), tx)
req = ValidateCodeRequest(
system="http://loinc.org",
code="8480-6", # Systolic blood pressure
value_set_url="http://hl7.org/fhir/ValueSet/observation-vitalsignresult",
code_system_version="2.77",
)
cache.validate_code(req)
cache.invalidate_release() # new terminology release
cache.validate_code(req)
assert tx.calls == 2 # re-fetched after the bump
The first test pins the whole point of the cache: two identical requests, one network call. The second proves the invalidation lever actually works — after invalidate_release, the same request re-hits the server because its key now carries a new prefix. Add a third test that two requests differing only in code_system_version produce different keys and therefore two calls; that is the regression guard against the omit-the-version bug reappearing after a refactor.
Gotchas & compliance constraints
- Never omit the code-system version from the key. A key of
system|code|valueSetlooks complete and works in every test that stays within one release. It fails the moment SNOMED CT or LOINC publishes and a code’sactivestatus flips — you will serve a staletruefor a retired code indefinitely. This is the single most common terminology-cache defect; the same release-drift concern shows up when mapping across systems in SNOMED CT to ICD-10 mapping strategies. - Keep negative-cache TTLs short. Caching
falseverdicts is essential for shielding the server from bad feeds, but a long negative TTL means a code that becomes valid in a new release stays “invalid” in your pipeline for weeks. A negative TTL of a few hours re-checks often enough to self-heal, while a version-prefix bump on release day flushes negatives immediately. - Keep PHI out of cache values and logs. Codes and value sets are reference data and are safe to cache and share, but the surrounding request/response context is not. Do not fold
Patient.id,subject.reference, encounter identifiers, or free-textdisplayderived from patient data into the key or the cached JSON, and scrub the same fields from any cache-miss or slow-path logging. A terminology cache that starts logging full request envelopes quietly becomes a PHI store outside your audit boundary. See the broader boundary discipline in the FHIR terminology server integration guide.
Pre-deploy checklist
A correct terminology cache is defined by its key, not its speed: encode the full validity semantics — system, code, value set, and code-system version — cache both verdicts with release-appropriate TTLs, and make invalidation a single prefix bump. Do that and $validate-code stops being the latency floor of your ETL without ever becoming a source of stale clinical truth.
Related
- FHIR terminology server integration — parent guide: wiring
$validate-code,$lookup, and$translateinto the pipeline - Building a FHIR CapabilityStatement for ETL systems — declaring the terminology capabilities your cache fronts
- SNOMED CT to ICD-10 mapping strategies — the release-drift problem that makes version-scoped keys mandatory
- FHIR & HL7 v2 standards architecture for clinical ETL — how terminology caching fits the wider standards pipeline