EDI 852 vs 846: Reconciliation Differences
How EDI 852 product activity and EDI 846 inventory inquiry differ in pharmacy reconciliation, and how a pipeline routes each
The 852 and the 846 are the two X12 transactions a pharmacy leans on for distributor reconciliation, and the single most common reconciliation bug is treating them as interchangeable inventory feeds. They answer different questions. The EDI 852 Product Activity Data transaction reports movement — what was received, dispensed, and left on hand over a reporting period — as a stream of signed deltas. The EDI 846 Inventory Inquiry/Advice transaction reports state — how many units are available right now — as a point-in-time snapshot. Feed both into the same accumulator and you double-count; assert the 846’s snapshot as a delta and you corrupt the running balance. Reconciliation done right uses them in complementary roles: accumulate the 852 movement to compute an expected on-hand, then use the 846 snapshot to validate that computation. A gap between the two is a reconciliation exception worth investigating, not a parser error. This how-to explains the distinction and implements a pipeline that classifies and reconciles both, within the parent reference, EDI 852 & 846 Parsing Pipelines.
Getting the roles right matters beyond tidiness. In a controlled-substance environment the computed-versus-reported comparison is a diversion tripwire: when the movement the distributor reports does not reconcile to the availability it reports, one of the two is wrong, and an unexplained gap on a Schedule II product is the kind of variance a compliance officer must be able to see and explain under 21 CFR § 1304.11.
Problem framing
The 852/846 parsing pipeline ingests both transaction sets, but they must not flow into the same reducer. The 852 is a delta source: each activity line moves the running balance up or down. The 846 is a state source: it asserts an absolute quantity that overwrites nothing and accumulates into nothing — it exists to be checked against the computed balance. The reconciliation engine therefore has two inputs with two different algebras, and its job is to hold them apart until the moment of comparison.
The comparison is the whole value. Accumulating 852 movement gives you what on-hand should be; the 846 tells you what the trading partner says it is. When they agree, the perpetual record is corroborated. When they disagree, exactly one of the following is true: a movement was missed or double-counted, the snapshot was taken at a different instant than the period boundary, or product was lost, substituted, or diverted. Surfacing that disagreement as a ticket — rather than silently trusting one feed — is what makes the reconciliation defensible.
EDI 852 vs 846: dimension-by-dimension
| Dimension | EDI 852 (Product Activity) | EDI 846 (Inventory Inquiry / Advice) |
|---|---|---|
| Question answered | What moved over a period? | What is available right now? |
| Data semantics | Signed deltas (received, dispensed, returned, on-hand) | Absolute point-in-time quantity |
| Direction | Wholesaler / distributor → pharmacy | Either direction between trading partners |
| Reporting cadence | Periodic activity report (daily/weekly) | On-demand or scheduled snapshot |
| Key quantity segment | QTY with an activity qualifier (QTY01 = 17/33/38) |
QTY with an availability qualifier |
| Reconciliation role | Drives the computed perpetual balance | Validates the computed balance |
| Sign convention | Positive = received/on-hand; negative = dispensed/returned | Positive = available; zero = stockout |
| Failure signal | Missing or duplicated movement warps the delta sum | Snapshot timing mismatch warps the assertion |
The row that governs the pipeline is reconciliation role: the 852 drives, the 846 validates. Everything else follows from that. Because the 852 carries signed movement, its records are folded into a running total per NDC; because the 846 carries an absolute state, its record is held aside and compared to that total at the period boundary. A pipeline that respects this division reconciles cleanly; one that blurs it produces phantom variance on every run.
Prerequisites & environment
- Python 3.11+, standard library only —
hashlibfor the audit hash,loggingfor the PHI-free reconciliation line,dataclassesandenumfor the typed records. Addpytestfor the test block. - Parsed input from both transaction sets. This recipe assumes the field-level extraction has already run; the per-item extraction of 852 segments is covered in EDI 852 Field Extraction with Python, and 846 items are extracted the same way with an availability qualifier.
- Normalized NDC keys. Both feeds must key on canonical NDC-11 so the same product from either transaction folds to one balance rather than two.
- Regulatory context. The perpetual-inventory standard of
21 CFR § 1304.11is why the computed-versus-reported comparison exists at all: an inventory that cannot be reconstructed and corroborated to a point in time is not compliant.
Implementation: classify, accumulate, reconcile
The engine classifies each interchange by its ST01 transaction-set code, folds 852 activity into a per-NDC running balance, records 846 availability as an assertion, and reconciles the two at the period boundary — emitting a balanced or exception result per product with a SHA-256 audit hash.
from __future__ import annotations
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
logger = logging.getLogger("pharmacy.edi.recon")
# 852 activity qualifiers and their sign toward on-hand.
_SIGN = {"17": +1.0, "33": +1.0, "38": -1.0, "41": -1.0, "QB": 0.0}
class TxnSet(str, Enum):
ACTIVITY_852 = "852"
INQUIRY_846 = "846"
@dataclass(frozen=True)
class EdiItem:
ndc: str
qualifier: str # QTY01
quantity: float # QTY02 (unsigned magnitude)
class ReconStatus(str, Enum):
BALANCED = "balanced"
EXCEPTION = "exception"
@dataclass(frozen=True)
class ReconResult:
ndc: str
computed_on_hand: float # from accumulated 852 deltas
reported_available: float # from the 846 snapshot
variance: float
status: ReconStatus
audit_hash: str
def classify(st01: str) -> TxnSet:
"""Route by transaction-set code; anything else is a routing error."""
try:
return TxnSet(st01)
except ValueError as exc:
raise ValueError(f"Unroutable transaction set: {st01!r}") from exc
class Reconciler:
"""Accumulate 852 movement, hold 846 state, and reconcile the two.
Supports 21 CFR § 1304.11 point-in-time inventory corroboration."""
def __init__(self, tolerance: float = 0.0) -> None:
self._computed: dict[str, float] = {} # NDC -> running on-hand
self._reported: dict[str, float] = {} # NDC -> 846 available
self._tolerance = tolerance
def apply(self, txn: TxnSet, items: list[EdiItem]) -> None:
if txn is TxnSet.ACTIVITY_852:
for it in items:
sign = _SIGN.get(it.qualifier, 0.0)
# On-hand (33) is an absolute checkpoint, not a delta: it seeds
# the running balance rather than adding to it.
if it.qualifier == "33":
self._computed[it.ndc] = it.quantity
else:
self._computed[it.ndc] = (
self._computed.get(it.ndc, 0.0) + sign * it.quantity
)
elif txn is TxnSet.INQUIRY_846:
for it in items:
self._reported[it.ndc] = it.quantity # absolute state assertion
def reconcile(self) -> list[ReconResult]:
results: list[ReconResult] = []
ndcs = set(self._computed) | set(self._reported)
ts = datetime.now(timezone.utc).isoformat()
for ndc in sorted(ndcs):
computed = self._computed.get(ndc, 0.0)
reported = self._reported.get(ndc, 0.0)
variance = round(computed - reported, 4)
status = (
ReconStatus.BALANCED
if abs(variance) <= self._tolerance
else ReconStatus.EXCEPTION
)
digest = hashlib.sha256(
f"{ndc}|{computed}|{reported}|{variance}|{ts}".encode()
).hexdigest()
result = ReconResult(ndc, computed, reported, variance, status, digest)
results.append(result)
# PHI-free audit line: product identity and quantities only.
logger.info(json.dumps({
"event": "recon_result", "ndc": ndc,
"computed": computed, "reported": reported,
"variance": variance, "status": status.value,
"audit_hash": digest,
}, sort_keys=True))
return results
if __name__ == "__main__":
r = Reconciler(tolerance=0.0)
# 852: seed on-hand 144, receive 50, dispense 60 => computed 134
r.apply(TxnSet.ACTIVITY_852, [
EdiItem("00093721410", "33", 144.0),
EdiItem("00093721410", "17", 50.0),
EdiItem("00093721410", "38", 60.0),
])
# 846: distributor reports 134 available => balanced
r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 134.0)])
for res in r.reconcile():
print(res.ndc, res.status.value, res.variance)
The two apply branches encode the whole distinction: 852 movement folds into a running total (with the on-hand qualifier 33 treated as an absolute checkpoint that seeds the balance rather than a delta that adds to it), while 846 availability is stored as a flat assertion. Only reconcile brings them together, and it never mutates either side — it just measures the gap and classifies it. A non-zero variance beyond tolerance becomes a ReconStatus.EXCEPTION, the signal that feeds a variance ticket.
Verification & testing
The tests prove that 852 deltas accumulate with correct signs, that 846 availability is compared rather than accumulated, and that a genuine gap raises an exception while an exact match balances.
import pytest
def test_852_deltas_accumulate_with_sign():
r = Reconciler()
r.apply(TxnSet.ACTIVITY_852, [
EdiItem("00093721410", "33", 100.0), # seed on-hand
EdiItem("00093721410", "17", 20.0), # +received
EdiItem("00093721410", "38", 15.0), # -dispensed
])
r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 105.0)])
[res] = r.reconcile()
assert res.computed_on_hand == 105.0
assert res.status is ReconStatus.BALANCED
def test_846_is_not_accumulated():
# Two 846 snapshots must not add up; the latest asserts state.
r = Reconciler()
r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 40.0)])
r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 42.0)])
[res] = r.reconcile()
assert res.reported_available == 42.0 # overwritten, not summed
def test_gap_raises_exception():
r = Reconciler(tolerance=0.0)
r.apply(TxnSet.ACTIVITY_852, [EdiItem("00093721410", "33", 136.0)])
r.apply(TxnSet.INQUIRY_846, [EdiItem("00093721410", "QA", 134.0)])
[res] = r.reconcile()
assert res.variance == 2.0
assert res.status is ReconStatus.EXCEPTION
def test_unroutable_transaction_set_is_rejected():
with pytest.raises(ValueError):
classify("810") # an invoice is not reconcilable here
A captured reconciliation line records the computed and reported quantities, the variance, and its status — product identity only, no patient data:
INFO pharmacy.edi.recon {"audit_hash": "3ac9...771e", "computed": 136.0, \
"event": "recon_result", "ndc": "00093721410", "reported": 134.0, \
"status": "exception", "variance": 2.0}
The variance line is the artifact a compliance officer reads: a non-zero gap on a controlled product is the corroboration failure that 21 CFR § 1304.11 requires be visible and explainable, and the audit hash makes any later edit to the result detectable.
Gotchas & compliance pitfalls
- Never accumulate the 846. The 846 asserts an absolute quantity; summing two snapshots doubles the reported availability and manufactures a variance that is not real. Store it as the latest state, and only compare it. This is the single most common 852/846 reconciliation defect.
- The 852 on-hand qualifier is a checkpoint, not a delta.
QTY01 = 33reports an absolute on-hand at the period, not an incremental movement. Adding it to the running balance instead of seeding the balance with it inflates the computed total. Treat33as a reset point and17/38/41as deltas. - Snapshot-timing mismatch masquerades as diversion. An 846 taken an hour after the 852 period closed reflects movement the 852 has not yet reported, so a small variance can be pure timing, not loss. Align the snapshot instant to the 852 period boundary, and treat a within-tolerance gap as timing noise rather than an alert — but never widen the tolerance far enough to hide a real controlled-substance shortage.
- Unroutable transaction sets. A functional group may carry an 810 invoice or an 856 ship-notice alongside the 852/846. Classifying strictly on
ST01and rejecting anything that is not reconcilable keeps a non-inventory transaction from silently entering the balance. Route rejects through the shared machinery in Error Handling & Retry Mechanisms rather than dropping them. - NDC identity forks the balance. If the 852 arrives as one NDC layout and the 846 as another, the same product folds into two keys and every product looks like a variance. Normalize both feeds to canonical NDC-11 before reconciliation.
Frequently Asked Questions
When should a pharmacy reconcile with the 852 versus the 846?
Use both, in complementary roles. Accumulate the 852 to compute what on-hand should be from received, dispensed, and returned movement, then use the 846 to validate that computed balance against the point-in-time availability the trading partner reports. Neither alone is a full reconciliation: the 852 gives movement without a corroborating state, and the 846 gives state without the movement that produced it. A mismatch between the two is a reconciliation exception, not a parsing error.
Why can’t I just add the 846 availability into my running inventory?
Because the 846 is an absolute snapshot, not a delta. Two 846 snapshots of 40 and 42 units do not mean 82 units are available — they mean the availability was 40, then 42. Accumulating an absolute-state transaction double-counts and produces phantom variance on every run. Store the latest 846 as the reported state and compare it; only the 852’s signed movement is ever accumulated.
What does a variance between computed and reported actually indicate?
One of three things: a movement was missed or double-counted in the 852 stream, the 846 snapshot was taken at a different instant than the 852 period boundary, or product was genuinely lost, substituted, or diverted. The reconciliation engine cannot tell which on its own — it raises the exception so a human can. On a controlled substance, an unexplained gap is exactly the signal that must remain visible under 21 CFR § 1304.11.
Does reconciliation touch protected health information?
No. Both the 852 and 846 are supply-chain transactions between trading partners; they carry NDCs, quantities, and dates, not patient data. The reconciliation engine emits only product identity, computed and reported quantities, the variance, and a SHA-256 audit hash, keeping the reconciliation log inside the HIPAA audit-control boundary of 45 CFR § 164.312(b).
Related
- Up: EDI 852 & 846 Parsing Pipelines — the parent reference where classification and reconciliation live.
- EDI 852 Field Extraction with Python — the per-item extraction that feeds this reconciler.
- Error Handling & Retry Mechanisms — where unroutable or malformed interchanges are quarantined and replayed.
- Data Ingestion & Inventory Sync Workflows — the ingestion architecture this reconciliation serves.