· 4 min read

You added one column to your test schema. Why did every other column change?

Here is a small thing that wastes a surprising amount of time.

You have a seeded data generator. Same seed, same rows, every run, which is the whole point. Then you add one column to the middle of a table, plan, say, between email and country. You regenerate. And country, signup_date, and revenue all come out different too, even though you did not touch them.

Nothing is broken. Your fixture is still valid. But your diff is now the entire file, and you have lost the ability to look at a change and see what actually changed.

Why it happens

Most seeded generators draw every value from one sequential random stream. Think of the seed as the starting position in a very long fixed list of random numbers, and generation as walking that list from left to right, taking numbers as it goes.

That works perfectly as long as you take the same numbers in the same order. The moment you insert a column, you insert 200 extra draws into the middle of the walk. Everything downstream shifts by 200 positions and reads different numbers. The values are still deterministic (regenerate twice and you get the same thing both times), they are just deterministic about a different arrangement than before.

So the guarantee you actually had was narrower than you thought. Not "the same seed gives the same data," but "the same seed and an unchanged schema give the same data." Schema edits are exactly the thing you do all day.

The fix: one stream per column, keyed by name

Instead of one stream walked left to right, derive an independent stream for each generation site from the seed and that site's stable name:

rng = derive_rng(seed, "column", "users", "revenue")

revenue now draws from a stream that depends only on the word revenue (and the table, and the seed). It does not care what columns exist beside it, how many there are, or what order they are in. Adding plan creates a new stream for plan and disturbs nothing else.

Misata calls this anchored generation, and as of 0.8.8.2 it is the default.

What it actually buys you

Here is the real comparison, five columns, then one new column inserted in the middle, counting how many of the original columns survive byte-identical:

anchored: added 1 column -> 5/5 existing columns byte-identical | changed: NONE
  legacy: added 1 column -> 2/5 existing columns byte-identical | changed: ['country', 'signup_date', 'revenue']

Under the old sequential behaviour, three of five columns changed for no reason. Under anchored, none did. Your diff shows one new column, which is what you actually did.

You can run that yourself:

import misata
from misata.schema import SchemaConfig, Table, Column

def build(extra: bool, mode: str):
    cols = [
        Column(name="id", type="int", unique=True,
               distribution_params={"min": 1, "max": 99999}),
        Column(name="email", type="text"),
        Column(name="country", type="text"),
        Column(name="signup_date", type="datetime",
               distribution_params={"start": "2025-01-01", "end": "2025-12-31"}),
        Column(name="revenue", type="float",
               distribution_params={"min": 0, "max": 500}),
    ]
    if extra:
        cols.insert(2, Column(name="plan", type="categorical",
                              distribution_params={"choices": ["free", "pro"]}))
    return SchemaConfig(name="u", seed=42, generation_mode=mode,
                        tables=[Table(name="users", row_count=200)],
                        columns={"users": cols})

for mode in ("anchored", "legacy"):
    a = misata.generate_from_schema(build(False, mode))["users"]
    b = misata.generate_from_schema(build(True, mode))["users"]
    shared = [c for c in a.columns if c in b.columns]
    changed = [c for c in shared if not a[c].equals(b[c])]
    print(mode, "changed:", changed or "NONE")

What still changes, and why that is correct

Anchored is not "nothing ever changes." Edits flow down the dependency graph, never sideways:

  • Edit a column, and that column re-rolls. Obviously.
  • A formula column that reads it re-rolls, because its inputs moved.
  • Child rows sampling its keys re-roll, because the keys moved.
  • An identity that rewrites it (a ledger that must balance, a waterfall that must reconcile) re-runs, because the constraint still has to hold.

Everything genuinely independent stays put. That is the distinction that makes a diff readable: things change when they have a reason to.

The honest caveat

Anchored and sequential produce different bytes for the same seed. They have to; that is the entire point. So when Misata made anchored the default in 0.8.8.2, seed=42 started producing different data than it did before.

Every structural guarantee is untouched: exact row counts, zero-orphan foreign keys, declared outcomes, ranges, enums. And reproducibility itself still holds, in the form that matters: same version, same seed, same mode, same bytes. If you have pinned to specific byte output from an older version, set generation_mode: "legacy" and keep the old stream.

We flipped it now, deliberately, while few enough people depend on exact bytes that the change costs almost nobody anything. Waiting would only have made it more expensive.

Why this matters more than it sounds

Test fixtures get reviewed. When a schema change rewrites 4,000 rows, nobody reads that diff, they approve it. Which means the one row that changed for a real reason, the one worth catching, goes through unread.

Making edits minimal is not a cosmetic nicety. It is what keeps a generated fixture reviewable, and a fixture nobody reviews is not really a test.

pip install misata

Misata is open source (MIT): github.com/rasinmuhammed/misata