Why I Stopped "Cleaning" Bad Data and Started Flagging It Instead
I built a small internal tool for a support/fraud team as a take-home assignment: a dispute outcome tracker that closes the gap between disputed transactions and their final outcomes. Go backend, SQLite, React frontend, no auth, no Docker — deliberately scoped down to match what the assignment actually asked for.
The interesting part wasn't the CRUD. It was the seed dataset.
The dataset had seven distinct problems
I was handed 220 rows of "real-looking" dispute data to import. Buried in it were seven anomalies that don't show up until you actually look:
- A duplicate
case_id— two completely different rows sharing the same identifier. - A row missing
user_identirely. - A case marked
status: openthat somehow already had anoutcome. - An
outcomevalue of"maybe"— which isn't a valid enum value in any outcome-tracking system I've seen. - A negative
amount(-42.5, on a transaction that should never be negative). - A
created_attimestamp dated in 2027 — from the future, relative to the rest of the dataset. - A case with a captured
outcomebut an emptyoutcome_note, which shouldn't be possible if the capture flow is followed correctly.
My first instinct, honestly, was to write an importer that quietly fixes what it can and drops what it can't. That instinct was wrong, and it took me a minute to see why.
Fixing bad data hides the thing you built the tool to find
The entire point of this tool is to give a fraud/support team visibility into disputes that need attention. If the import script silently "fixes" a negative amount by flipping its sign, or drops the duplicate case_id, or quietly reclassifies "maybe" as "pending" — the team loses the one signal that actually matters: something upstream is broken, and a human needs to know.
So I flipped the approach. Every anomaly gets imported as-is, alongside a new ingest_warning column that flags exactly what's wrong with it. An analyst can pull up GET /api/cases?has_warning=true and see every flagged row in one place, with the specific issue attached. Nothing is silently dropped. Nothing is silently repaired.
There was one exception, and it's worth explaining because it's not actually a contradiction. The "maybe" outcome value never gets written into the outcome column — because doing so would corrupt every trend and count query downstream that assumes outcome only ever holds valid enum values. But the original "maybe" string isn't discarded either — it's preserved in the warning text, so nothing about the anomaly is lost. The rule isn't "never touch invalid data." It's "never let invalid data quietly become valid-looking data."
Auditability mattered more than mutability
The other design decision that shaped everything: outcomes aren't stored as a single mutable field that gets overwritten every time someone corrects it. Instead, there are two tables — cases, which holds the current denormalized state, and outcome_events, an append-only ledger where every capture and every correction is a new row.
This meant a correction to an outcome required a reason — the first capture doesn't, but any correction after that does, and the API rejects a reasonless correction without mutating the row at all. That single rule is enforced and tested directly against a real (in-memory) SQLite database, not mocked out, because it's the kind of rule that's easy to get subtly wrong at the boundary.
I also masked user_email and device_id by default in the list view — showing something like da***@inbox.test instead of the full address — with a ?reveal=true param and the single-case detail view as the only ways to see the unmasked value. It's not bulletproof PII protection, and I said so directly in the design doc rather than pretending it was more than it is. But defaulting to masked, rather than defaulting to exposed, felt like the right failure mode for a tool that fraud analysts would be using daily.
Testing the anomalies, not just the happy path
Every one of the seven seed-data anomalies got its own named unit test — things like TestNormalizeSeedRow_DuplicateCaseID_CASE00213 — so if a future change to the import logic breaks handling for one specific case, the test failure names the actual case_id that broke. That's a small thing, but it turns "some validation test failed" into "CASE-00213's duplicate handling regressed," which is a much faster thing to debug at 11pm.
What I'd tell someone building something similar
If you're building an ingestion pipeline for messy real-world data — disputes, transactions, support tickets, anything with a human downstream who needs to act on it — resist the urge to make your importer "smart" about cleaning things up. Smart importers that silently correct bad input are optimizing for a clean-looking database at the cost of the one thing your users actually need: knowing where the data is untrustworthy.
Flag it, log it, make it queryable. Let a human decide what "fixed" means. Your job is to make the mess visible, not to make it disappear.
Top comments (0)