---
title: "Same Root Cause Is Not Same Fix: Verify the Data Shape, Not Just the Symptom"
canonical: https://dxdev.com/blog/same-root-cause-is-not-same-fix-verify-the-shape/
datePublished: 2026-04-30
---
Two customer logins broke the same week. Same symptom both times: "I'm logged in but I'm looking at the wrong team." Same root cause both times: a stale role row in the table the login path walks, left over from last season. If you stopped reading at "same root cause," you'd reach for the first fix you wrote, run it against the second account, and quietly destroy the wrong record. I almost did. The thing that saved it was forcing myself to look at the actual rows for each account before reusing anything. The two shapes were not the same, and the fixes turned out to be exact opposites.

## The shared root cause

The product is a sports SaaS. People belong to teams through role rows: this person is a player on that team, this person is a coach on this other one. When someone logs in, the app walks their set of role rows to figure out which teams to show them and which one to land them on. It's the natural design, and it's also a magnet for stale data, because three different real-world events all mutate the same table: a player gets traded, a player legitimately plays for two teams in the same league, and a new season rolls a roster forward. Any one of those can leave a role row pointing somewhere it shouldn't.

So when two accounts surfaced the same week with "wrong team on login," the root cause was genuinely identical: a role row from last season that the login walk was still honoring. That's where a lot of triage stops. You've found the cause, you've already written the repair, you move on.

Here's the problem. "A stale role row" describes a *category*. It doesn't describe a *change*. The change you need to make depends entirely on what the rest of that account's rows look like, and those differed.

## Shape A: traded, needs the row re-pointed

The first account was a player who got traded between teams. Her login routed her to her old, now-inactive team. When I pulled her role rows, there was exactly one relevant row, and it still pointed at the old team. The new team's player record existed; the role just hadn't followed her over.

The fix here is a *re-point*. You take the existing role row and update it to reference the new team's player record instead of the old one. You do not delete anything, because there is no extra row to remove. The row is correct in kind, just wrong in target. Delete it and she loses her membership entirely.

There was a second wrinkle that made the re-point more than a one-column update. Her email-confirmed status lived on the old player record, and the new record showed unconfirmed. So the fix also had to carry the email-confirm timestamp forward. That, incidentally, is where the datetime gotcha lived: reading the date into the application layer and writing it back stringifies it into a format the database refuses to parse on the way in. The fix was to never let the value leave SQL, copying it column-to-column with a correlated subquery. That gotcha is its own post. What matters here is that Shape A's whole repair is "update what exists, carry one field forward, delete nothing."

## Shape B: two live rows, needs the stale one deleted

The second account had the same symptom and the same root-cause category. The login showed the wrong team. But the row picture was different. This person had *two* role rows: a coach role on a team from last season, and a player role on a current team. Both were surfacing in their account, and the stale coach role was the one polluting the view.

The fix here is a *delete*. There is no re-pointing to do, because the current row is already correct. The problem is purely the *presence* of the extra, stale row. You remove the coach role, leave the orphaned record behind for an admin to clean up, and you're done.

Now line the two up:

- Shape A: one row, wrong target. Fix = re-point it. Delete count: zero.
- Shape B: two rows, one current and one stale. Fix = delete the stale one. Update count: zero.

The two repairs are not variations on a theme. They are opposites. Shape A's repair never deletes; Shape B's repair never updates. If I had taken the script I wrote for the traded player and run it against the two-row account, the re-point logic would have aimed at the wrong row and rewritten data that was already correct, while leaving the actual stale row sitting right where it was. Same symptom, same root-cause label, and a repair that would have made things worse.

## Why the symptom and the root cause both lie to you

The trap is that the symptom and the root cause are exactly the parts that *do* match. "Wrong team on login" matched. "Stale role row" matched. Every field you'd put in the title of the bug matched. The thing that differed was the part you only see if you actually query the rows for *that* account and look at their structure: how many there are, which is current, what each points at.

That's the general shape of this failure, well beyond role rows. Two null-pointer crashes with the same stack trace can have different upstream causes. Two "duplicate key" errors can need a merge in one case and a delete in the other. The error message and even the diagnosed cause are a category, and a category is not a repair plan. The repair plan is a function of the surrounding data shape, and you can only know the shape by looking at the actual rows, not at the ticket and not at the last fix you wrote.

This is especially easy to get wrong as a solo dev, because you wrote the first fix yourself, ten minutes ago, and it's right there. Reuse is the path of least resistance and it feels responsible, not reckless. You're not copy-pasting a stranger's Stack Overflow answer; you're reusing your own correct, tested code. It's still wrong, because it was correct for a different shape.

## What I actually did instead

Each account got its own one-shot repair script, and each script asserted its own shape before touching anything. Not "run the same script with a different ID," which is the move that conflates them. Separate scripts.

Both scripts followed the same defensive skeleton, which is worth stealing on its own:

- **Dry run by default.** The script reports what it *would* do and writes nothing unless you explicitly pass a commit flag. The default invocation is safe.
- **Assert the shape before any write.** Confirm the person exists. Confirm the records you expect to exist actually do. Confirm the last names match the name on the ticket so you know you've got the right human. And confirm the row layout: Shape A's script asserts the role row points at the team it expects before re-pointing it; Shape B's script asserts there are two rows and that the one it's about to delete is the stale one. Any mismatch and the script writes its report and aborts. It refuses to run against a shape it wasn't written for.
- **Idempotent.** If the repair already partially ran, the script detects the already-applied state and resumes from where it left off instead of erroring or double-applying.
- **Emit a before/after report.** The output is a machine-readable snapshot of every row it touched, which goes into the ticket as the evidence of what changed.

The assertions are the part that matters. Shape B's script, run against Shape A's account, fails its own pre-flight: it expects two rows and finds one, so it aborts and writes a report saying so, instead of deleting the only row the traded player has. The script refuses to be the wrong fix. That refusal is the whole point. It turns "I have to remember these are different" into "the tool won't let me conflate them," which is the only version that survives a tired Thursday afternoon.

## Related
