A table needed four columns, a self-referencing constraint, and an index. The first migration added everything in one schema callback. That was concise, but production could already contain any subset of those objects.
The repair was longer. Each column, constraint, and index became a separate guarded migration. The constraint waited for its column. The index waited for both of its columns. Rollback deliberately removed nothing because some schema objects might predate the repair.
By line count, the repair was larger. By review burden, it made one decision clear: converge every supported starting state on the same schema without destroying state the repair did not necessarily create.
A small change is not merely a short diff. It limits how many independent decisions and possible states a reviewer must hold at once.
Lines are a weak unit of change
A one-line edit can change authorization for every request. A hundred-line fixture can describe one behavior without changing production at all. File count has the same problem: six migrations may isolate six operations, while one migration may combine all six.
Review size has more useful dimensions:
decision count
How many independently disputable choices are present?
starting states
How many valid states can the change encounter?
effects
Which writes, calls, locks, and irreversible actions can occur?
failure boundaries
Where can execution stop, and what remains afterward?
proof burden
Which claims require separate evidence?These dimensions explain why “keep pull requests under 300 lines” is an unreliable rule. The number may encourage discipline, but it cannot tell whether the change is understandable or safe.
The better question is whether the reviewer can state the invariant, enumerate the supported starting states, and connect each risk to evidence without mentally decomposing an unrelated redesign at the same time.
One invariant can require several execution units
The retained schema repair had one target invariant:
required columns exist
AND the self-reference is valid
AND the lookup index exists
AND existing rows surviveThat did not mean every operation belonged in one database statement. The operations had different prerequisites and could already have been applied independently.
Splitting them created an ordered dependency graph:
parent column ──> self-reference
type column ────> composite index
cart owner ─────> composite index
other columns ──> no schema dependencyEach migration could now answer a narrow question:
- Is the table present?
- Is this column absent?
- Does this constraint already exist?
- Are the index columns present?
- Does the index already exist?
That is mechanical decomposition. It reduces the state space inside each execution unit while keeping the review focused on one convergence decision.
Partial state is part of the input
Migration review often assumes two states: before and after. Recovery work rarely has that luxury.
A failed or quarantined rollout may leave:
- no new objects;
- some columns but no constraint;
- a column without its intended index;
- the complete schema with missing migration history; or
- the complete schema and complete history.
The repair must define what each starting state means. “Run the migration again” is not a plan when a bundled statement fails on the first object which already exists.
The retained test exercised clean state, complete state, a missing constraint, a missing index, and a production-like state where the earlier bundled migration intentionally did nothing. It ran the repair repeatedly and checked that existing row data survived.
That evidence is narrower than a production claim. It does not establish lock duration, engine-specific online DDL behavior, replication impact, row counts, or what any live database contained. It proves the application-level convergence cases represented by the test.
The synthetic reproduction makes the distinction smaller. A bundled operation accepts only an empty starting state. Independently guarded operations converge every declared partial state and reject an invalid dependency order.
A coherent change still has a boundary
“Make the pull request smaller” can produce a worse result when it separates code which cannot be reviewed or deployed independently.
Adding a nullable column may be safe on its own. Making application code read that column before every database has it is not. Adding an index may be operationally independent, yet the feature depending on its query plan may not be.
A useful change boundary has three properties:
- It owns one reviewable decision.
- Its intermediate state is supported or unreachable.
- Its proof can fail without leaving the next change ambiguous.
The schema repair kept the operations separate but the invariant together. Reviewers still had to inspect the whole dependency order, confirm the final schema contract, and decide whether non-destructive rollback was appropriate.
This is the difference between decomposing execution and fragmenting intent.
Rollback must respect ownership
An ordinary migration often treats down() as the inverse of up(): create a
column, then drop it on rollback.
That symmetry was unsafe for the repair. A guarded up() could encounter a
column which already existed. The migration could not later know whether it
had created that column or merely observed it. Dropping the object would risk
destroying schema owned by an earlier deployment or manual repair.
The retained migrations therefore used non-destructive rollback. That choice trades automatic reversal for ownership safety.
It is not a universal rule. A newly introduced schema object with a known owner can have a precise inverse. A destructive correction may require a forward repair, backup, or recorded provenance instead. The point is to review rollback as another state transition, not as generated ceremony.
Ask:
- What did this execution actually create?
- Can that ownership be proven later?
- Does rollback preserve rows written after the change?
- Will older application versions tolerate the repaired schema?
- Is forward correction safer than structural deletion?
If those answers differ by operation, one broad rollback is hiding decisions.
Split when decisions can move independently
Separate pull requests are useful when one choice can be accepted, reverted, or delayed without changing the meaning of another.
Good candidates include:
- an additive schema capability before application adoption;
- instrumentation before a risky behavior change;
- a mechanical rename before a semantic refactor;
- a performance index before a query starts depending on its budget; and
- cleanup after consumers have demonstrably left an old path.
Keep work together when separation would create an unsupported intermediate state, duplicate the same proof, or force reviewers to reconstruct one decision across several queues.
The credible alternative is a single broad migration on a new, controlled database. If every starting schema is known, the operation is transactional, the table is small, and failure can be retried from a clean state, splitting every column into its own file may add ceremony without reducing risk.
The deciding factor is not a preference for tiny pull requests. It is the number of independently changing assumptions.
Review the decision before counting the diff
A compact review can follow this order:
| Question | Evidence |
|---|---|
| What one decision is requested? | invariant and explicit non-goals |
| Which starting states are supported? | state matrix or fixtures |
| Which operations depend on others? | dependency graph and ordering |
| What can remain after failure? | interruption cases |
| Who owns rollback? | provenance and compatibility argument |
| What proves the final state? | behavior and schema contract checks |
If the first answer contains three unrelated verbs, the change probably needs another boundary. If the state matrix keeps expanding because a nearby refactor was included, move the refactor. If two operations require different deployment or rollback decisions, let them move independently.
Small changes help because they reduce simultaneous uncertainty. They give a reviewer fewer contracts to reconstruct, fewer failure states to simulate, and fewer reasons to approve one risky choice merely to obtain an unrelated useful one.
Do not optimize for the shortest diff. Optimize for the smallest coherent decision whose starting states, effects, and proof fit in one review.