Classifying Denials by CARC Group Code
Problem: your denial dashboard says the denial rate jumped, but half of what it counts is not a denial at all — patient deductibles under group PR and contractual write-downs under CO CARC 45 are being lumped in with genuine authorization and medical-necessity rejections. Before you can route, appeal, or measure anything, every X12 835 CAS adjustment must be sorted into an actionable bucket: write-off, appealable, patient-bill, or rework. This guide builds a deterministic classifier keyed on the CAS01 group code and the CARC that keeps those buckets clean. It underpins the router in CARC/RARC Denial Routing and targets revenue cycle engineers and Python billing developers.
Prerequisites
Spec Reference: Group Code to Actionable Bucket
The group code is the first-order signal for the bucket; the CARC refines the edge cases. The mapping below is the classifier’s backbone — note that PR is never a denial and that CO splits between a pure write-off (CARC 45) and appealable denials.
| CAS01 group | Meaning | Default bucket | Key exception |
|---|---|---|---|
CO |
Contractual Obligation | Appealable / rework | CO 45 (fee-schedule) and CO 253 (sequestration) are write-offs, not appealable |
PR |
Patient Responsibility | Patient-bill | Never a denial — deductible, copay, coinsurance |
OA |
Other Adjustment | Rework | OA 23 (prior payer adjudication) is informational for COB |
PI |
Payer-Initiated Reduction | Appealable | Payer chose to reduce without a contract basis — often appealable |
CR |
Correction and Reversal | Write-off / repost | Paired with a matching positive/negative reversal; nets to zero |
The four actionable buckets the classifier emits are: WRITE_OFF (post the adjustment, no pursuit), APPEALABLE (recoverable with documentation), PATIENT_BILL (move to the patient statement), and REWORK (fix and resubmit the claim).
Step-by-Step Implementation
Step 1 — Define the bucket enum and group codes
Model the outcome as a closed enum so downstream code can match on it exhaustively, and model group codes as a StrEnum so they compare directly against the parsed CAS01 string.
from __future__ import annotations
import enum
from dataclasses import dataclass
from decimal import Decimal
class GroupCode(enum.StrEnum):
CO = "CO" # Contractual Obligation
PR = "PR" # Patient Responsibility
OA = "OA" # Other Adjustment
PI = "PI" # Payer Initiated Reduction
CR = "CR" # Correction and Reversal
class Bucket(enum.StrEnum):
WRITE_OFF = "WRITE_OFF" # post adjustment, do not pursue
APPEALABLE = "APPEALABLE" # recoverable with documentation
PATIENT_BILL = "PATIENT_BILL" # move to patient statement
REWORK = "REWORK" # correct and resubmit
@dataclass(frozen=True)
class Adjustment:
group: GroupCode
carc: str
amount: Decimal
Step 2 — Encode the CARC exceptions
Some CARCs override their group’s default bucket. Keep these as explicit sets so the intent is auditable and a new payer behavior is a one-line data change, not a control-flow edit.
# CO CARCs that are pure contractual write-offs, never appealable.
CO_WRITE_OFF_CARCS: frozenset[str] = frozenset({
"45", # charge exceeds fee schedule / maximum allowable
"253", # sequestration — federal payment reduction
"59", # multiple/concurrent procedure reduction
})
# CO CARCs that mean "fix the claim and resubmit" rather than appeal.
CO_REWORK_CARCS: frozenset[str] = frozenset({
"16", # lacks information / billing error (usually with a RARC)
"97", # bundled — review NCCI, consider a modifier
})
Step 3 — Write the classification rules
Classify on the group code first, then apply CARC exceptions. The match statement makes the group-level defaults explicit; the CO branch is the only one that consults the CARC sets.
def classify(adj: Adjustment) -> Bucket:
"""Sort one CAS adjustment into an actionable bucket."""
match adj.group:
case GroupCode.PR:
# Patient responsibility is a balance, never a denial.
return Bucket.PATIENT_BILL
case GroupCode.CR:
# Correction/reversal nets against a prior posting.
return Bucket.WRITE_OFF
case GroupCode.CO:
if adj.carc in CO_WRITE_OFF_CARCS:
return Bucket.WRITE_OFF
if adj.carc in CO_REWORK_CARCS:
return Bucket.REWORK
return Bucket.APPEALABLE
case GroupCode.PI:
# Payer chose to reduce without a contract basis — pursue it.
return Bucket.APPEALABLE
case GroupCode.OA:
return Bucket.REWORK
# Unreachable for valid group codes; guard against malformed 835.
raise ValueError(f"unhandled group code: {adj.group!r}")
def is_denial(bucket: Bucket) -> bool:
"""Only appealable and reworkable adjustments count as denials."""
return bucket in (Bucket.APPEALABLE, Bucket.REWORK)
Step 4 — Classify a claim and keep the denial count honest
Classify every triplet on the claim, then derive the denial flag. A claim can carry a write-off and an appealable denial in the same CAS segment, so classify per adjustment, never per claim.
def classify_claim(adjustments: list[Adjustment]) -> list[tuple[Adjustment, Bucket, bool]]:
results = []
for adj in adjustments:
bucket = classify(adj)
results.append((adj, bucket, is_denial(bucket)))
return results
sample = [
Adjustment(GroupCode.CO, "45", Decimal("31.40")), # write-off
Adjustment(GroupCode.CO, "197", Decimal("120.00")), # appealable
Adjustment(GroupCode.PR, "1", Decimal("25.00")), # patient bill
]
for adj, bucket, denial in classify_claim(sample):
print(adj.group, adj.carc, "->", bucket, "denial" if denial else "not a denial")
Verification
Confirm the three edge cases that distort denial metrics most: a PR deductible is not a denial, a CO 45 write-down is not a denial, and a CO 197 authorization rejection is. Then assert the claim balances so no adjustment was lost in classification.
assert classify(Adjustment(GroupCode.PR, "1", Decimal("25.00"))) == Bucket.PATIENT_BILL
assert not is_denial(Bucket.PATIENT_BILL)
assert classify(Adjustment(GroupCode.CO, "45", Decimal("31.40"))) == Bucket.WRITE_OFF
assert not is_denial(Bucket.WRITE_OFF)
assert classify(Adjustment(GroupCode.CO, "197", Decimal("120.00"))) == Bucket.APPEALABLE
assert is_denial(Bucket.APPEALABLE)
# Balancing guard: CLP03 charge == CLP04 paid + patient resp + sum(CAS amounts).
charge, paid, patient = Decimal("176.40"), Decimal("0.00"), Decimal("25.00")
total_adj = sum(a.amount for a in sample) # 31.40 + 120.00 + 25.00
assert charge == paid + total_adj # patient PR is itself a CAS PR triplet here
Expected classification output:
CO 45 -> WRITE_OFF not a denial
CO 197 -> APPEALABLE denial
PR 1 -> PATIENT_BILL not a denial
Only one of the three adjustments counts toward the denial rate — exactly the discrimination a raw “count of CAS segments” metric fails to make.
Common Gotchas
- PR is not a denial. Deductible (CARC 1), coinsurance (CARC 2), and copay (CARC 3) under group
PRare the patient’s balance. Counting them as denials inflates the rate and sends staff chasing money the payer never owed. - CO 45 is contractual, not a denial. Fee-schedule write-downs are the amount you agreed to forgo in the payer contract. They belong in
WRITE_OFF, never in an appeal queue — appealing a contracted adjustment is unwinnable and wastes staff time. - Balancing amounts must reconcile. For every claim,
CLP03must equalCLP04plus patient responsibility plus the signed sum of all CAS amounts. If your classified buckets do not account for every triplet, the claim will not balance — treat a mismatch as a parsing bug, not a routing decision. - CR reversals come in pairs. A correction/reversal group adjustment usually offsets a prior posting with an equal, opposite amount. Classify it as a repost, not a denial, and match it to its original so the ledger nets correctly.
Related
- Parent guide: CARC/RARC Denial Routing — the CAS segment and code vocabulary these buckets classify.
- Building a CARC/RARC Routing Engine — feeds this classifier’s
is_denialflag into a versioned, config-driven routing table. - Denial Analytics and Reporting — the KPIs that depend on these buckets excluding write-offs and patient balances.