localis
Waitlist

Describe a service in plain words, or type a CPT/HCPCS code.

6 min read

What 13 years of CMS spreadsheet headers taught us about parsing by name

The Physician Fee Schedule ingests one family of CMS files every quarter — PPRRVU (the RVU table), GPCI (locality cost indexes), ANES (anesthesia conversion factors) — going back to 2013 for historical lookups. CMS publishes these as spreadsheets for CMS's own purposes, not as a stable API contract for ours, and the column layout reflects that: labels move, get renamed, get abbreviated differently, and occasionally encode a value directly into the header text. Reading column N because column N held the right value last quarter is a bet that CMS didn't change anything, and over thirteen years of releases that bet loses regularly enough that we stopped making it. Every column is found by matching its header text against an alias table, never by position.

Headers that span more than one row

Some CMS files don't even give you a single header row to match against. PPRRVU and GPCI wrap column labels across two to four rows, with CMS often repeating a label like "NON-FAC" on consecutive rows rather than merging cells. HeaderMapper stacks each column's cells vertically, drops adjacent repeats, and joins what's left:

$combined = [];
for ($col = 0; $col < $columnCount; $col++) {
    $parts = [];
    foreach ($headerRows as $row) {
        $cell = trim((string) ($row[$col] ?? ''));
        if ($cell !== '' && ($parts === [] || end($parts) !== $cell)) {
            $parts[] = $cell;
        }
    }
    $combined[$col] = self::normalize(implode(' ', $parts));
}

Normalizing uppercases the joined text, collapses everything that isn't A-Z0-9 to underscores, and strips embedded 4-digit years — GPCI headers carry the release year inline ("2026 PW GPCI"), and without stripping that, the same logical column would need a fresh alias every single release:

$label = preg_replace('/(?:^|_)(19|20)\d{2}(?=_|$)/', '_', $label) ?? '';

The alias table is a changelog

Once a header normalizes to something stable, it's matched against a per-canonical-column alias list — and that list, read straight from config/pfs.php, is really a record of every way CMS has phrased the same column since 2013:

'MAC' => ['MEDICARE_ADMINISTRATIVE_CONTRACTOR_MAC', 'MEDICARE_ADMINISTRATIVE_CONTRACTOR', 'MAC', 'CONTRACTOR', 'CARRIER'],
Years Header text
2013–2017 "Carrier"
2018–2019, 2021–2022 "Medicare Administrative Contractor" (no suffix)
2020, 2023+ "Medicare Administrative Contractor (MAC)"

Same column, same meaning, four different labels depending on which year you ask. A fixed-index parser wouldn't just miss the rename — it would silently read some column under the new layout and compute a wrong answer from real data, which is worse than failing to parse at all.

When the header contains a number that changes every release

The 2026 anesthesia conversion factor file broke exact string matching outright. Its header doesn't just name the column — it states the value:

"Qualifying APM National Anes CF (with 2.5% statutory increase) of 20.599835"

That trailing number is different in every release by definition; it is the conversion factor. An alias ending in * prefix-matches instead of requiring equality, so the alias table names the stable part of the header and lets the release-specific suffix vary:

'QUALIFYING_APM_NATIONAL_ANES_CF*',
private function matches(string $label, array $accepted): bool
{
    foreach ($accepted as $alias) {
        if (str_ends_with($alias, '*')) {
            $prefix = self::normalize(substr($alias, 0, -1));
            if ($prefix !== '' && str_starts_with($label, $prefix)) {
                return true;
            }
        } elseif ($label === self::normalize($alias)) {
            return true;
        }
    }

    return false;
}

The non-QPP counterpart needed the same treatment for a different reason: its alias has to prefix-match "Non-Qualifying APM..." without also matching the QPP column, since one normalizes with a NON_ prefix the other doesn't have. Getting the two confused would silently apply the wrong conversion factor to every anesthesia code in the release — the kind of bug that produces a plausible, wrong dollar amount rather than an error.

Finding the right column doesn't mean parsing it right

The alias table gets you to the correct column. It doesn't guarantee the value in that column means what every other release's value means. The 2015 ANES file abbreviates its header to "2015 Anes. Conversion Factor" — matched fine — but encodes the conversion factor as an integer 100× the real dollar value: "2148" in the file means $21.48. An alias alone would ingest $2,148.00 anesthesia pricing without complaint, because nothing about matching the header name tells you the file's numeric encoding changed too. That case is handled downstream, in the value normalizer, not in the alias table — a reminder that "found the column" and "understood the column" are different claims, and conflating them is exactly how a plausible-looking bad number gets into a release.

The bug that motivated isAlias()

For a while, header-row detection — figuring out which row of a file is the header, before resolve() can map its columns — didn't use this alias table at all. The NCCI PTP parser looked for the literal string "Column 1" to find its header row. That string stopped appearing in real CMS files at some point, which meant the parser silently failed to find a header row and skipped the columns entirely. Nothing about that failure mode raised an error; it just meant /code/99213 showed no NCCI edits in production, and that was the first anyone noticed.

The fix was HeaderMapper::isAlias() — a method that lets header-row detection ask the same alias table column mapping already trusts, instead of maintaining a second, driftable idea of what a header looks like:

public function isAlias(string $canonical, string $normalizedLabel): bool
{
    return $this->matches($normalizedLabel, $this->aliases[$canonical] ?? []);
}

Two places that both need to recognize "this is the header row" should share one definition of what that means. A hardcoded literal is a second definition waiting to go stale independently of the first.

Fail loud, not quiet

The one thing every mechanism above shares: if a required column truly can't be found, resolve() throws with the labels it did resolve attached, rather than guessing:

$missing = array_values(array_diff($this->required, array_keys($map)));
if ($missing !== []) {
    throw new UnsupportedFormatException(sprintf(
        'HeaderMapper: required columns not found: %s. Resolved labels: %s',
        implode(', ', $missing),
        implode(' | ', array_filter($combined)),
    ));
}

An ingest that fails loudly on a genuinely new CMS layout is an hour of someone adding an alias. An ingest that guesses wrong on a genuinely new layout is a wrong rate shipped to every customer who looks up that code until someone notices — and given the "Column 1" incident, "someone notices" is not a fast backstop to rely on.

The pattern

None of this is exotic parsing technique. It's the accumulated, unglamorous cost of taking a data source seriously when you don't control its format: match by name because position isn't a contract, let the alias table absorb rewording instead of pretending the source is stable, handle values that change encoding even when the column name doesn't, and make sure every place in the code that needs to recognize the same thing — a header row, a canonical column — agrees on one definition instead of keeping two that can quietly drift apart.

← All posts