← Statpro dev log

2026-07-16

Phantom players and the 9/11 standings bug

sportsdataproduct

When the data lies about who played

Two data-quality bugs in Statpro this week, both in the merge layer, both the kind of thing that makes you distrust your own numbers.

The 9/11 standings bug

Rescheduled MLB games re-list in the season schedule under the same gamePk. The season feed carries both the postponed listing and the makeup listing, and the makeup listing has the actual score. My dedup kept the first listing it saw. So roughly a thousand games across 2000-2025 ended up as scoreless "finals" because the postponed entry came first in the feed.

The damage was real: the 2008 Yankees showed 86-70 instead of 89-73. The 2001 season had 123 affected games, the 9/11 postponement wave. Every historical standings record was shorted by however many games got rescheduled that year.

The fix is a ranking function. Instead of first-listing-wins, the dedup now keeps the most resolved listing: a final with scores beats a final without, which beats live or scheduled, which beats postponed or cancelled.

const STATUS_LISTING_RANK: Record = {
  final: 2, in_progress: 1, scheduled: 1, postponed: 0, cancelled: 0,
};
function listingRank(game: z.infer): number {
  if (status === "final" && listingScored(game)) return 3;
  return STATUS_LISTING_RANK[status];
}

Simple ranking, but the bug hid for years because the standings still looked plausible. 86-70 isn't obviously wrong unless you remember the Yankees won the division that year.

Phantom NBA players

The second bug was weirder. Early-2000s NBA box scores from the stats API pad rosters with phantom rows: sequential pseudo player IDs that don't exist in the league registry, blank names, all-null stats. My merge code was minting real identities for these placeholders. 282 phantom players, 47,928 stat rows that carried exactly two real values across all of them.

The repair was two parts. First, skip the phantom records at merge time instead of creating identities from them (blank name, no minutes, no points = skip). Second, a purge script that transactionally deletes the valueless rows: stat entries, external IDs, and the placeholder players. Any placeholder that somehow accrued real stat values stays, just flagged as name-missing.

Both bugs share a shape: the pipeline was faithfully ingesting what the source gave it, and the source was giving it garbage dressed up as data. The fix in both cases wasn't rejecting the source. It was teaching the merge to distinguish a real record from a placeholder, and keeping the placeholder out of the canonical tables.

this is the build log of statpro · statpro.io · all entries · essays