Backend

Design a migration that can be checked before it writes

Kamruz
Kamruz
Oct 14, 2026
5 min read
Design a migration that can be checked before it writes

A migration is meant to add a normalized slug to older articles. The script loops through every record and overwrites the slug field, including articles already edited by an administrator. Its dry run prints a count but not the proposed values. After the write, there is no record of which original values changed. The script was executable, but the change was not reviewable before execution.

This example assumes MongoDB and a Node migration tool running against a clearly identified environment. A migration is a controlled data transformation with preconditions, invariants, and recovery. It is not simply application code executed in a loop. The ability to run it twice safely is valuable even when the first run is expected to finish.

Define the transformation and its limits

State which records qualify: legacy articles with no normalized slug, not every article. Define the transformation from existing title and identifier, including collision handling. Decide whether a title change after migration should alter the slug or preserve public identity.

Write invariants before implementation. Existing valid slugs remain unchanged. New slugs satisfy the allowed format. Published URLs are not silently broken. Two articles cannot receive the same unique public slug. A collision is a review item, not a reason to append a random suffix without a product rule.

Identify concurrent writers. Editors can update an article while the migration runs. A preview is a snapshot of intent, not permission to overwrite a later value. The write needs a condition proving the record still meets the migration precondition.

Build a useful dry run

A preview should show candidate identifier, relevant original values, proposed values, reason, and conflict status. Include counts for changed, unchanged, invalid, and conflicting records. Use a bounded sample for human review and a machine-readable artifact for the full plan.

Do not include full article bodies or private fields unless needed for the transformation. The preview artifact inherits the data's access requirements. A harmless slug migration does not need a broad database export.

Have the dry run execute the same transformation function as the write mode. Separate pure mapping from persistence so review does not inspect a second implementation that can drift. Tests should cover the mapping with synthetic legacy, valid, malformed, and conflicting records.

Add write-time preconditions

Use conditional updates that include the expected old value, absence condition, or revision. If the record changed after preview, skip and report it rather than overwrite it. This is optimistic concurrency for the migration's own assumptions.

const result = await articles.updateOne(
{ _id: planned.id, revision: planned.oldRevision, slug: { $exists: false } },
{ $set: { slug: planned.newSlug }, $inc: { revision: 1 } }
)
if (result.matchedCount !== 1) reportConcurrentChange(planned.id)

The example assumes a real revision field and a precise legacy absence rule. Null and empty-string slugs may require separate explicit handling. Do not broaden the filter to match them accidentally. A unique index can enforce final slug uniqueness, but existing conflicts must be resolved before installing it.

Record outcomes per identifier. A batch can partially succeed unless its transaction semantics say otherwise. Bulk operation summaries alone may not contain enough detail to resume or reverse the exact transformation.

Process bounded batches

Traverse with a stable identifier order and a checkpoint. Limit batch size and work per request according to the environment. Avoid offset pagination for a set that changes as migrated records leave the candidate filter; offsets can skip records.

Make restart behavior explicit. A second run should ignore already transformed records or verify that their migration marker matches the intended result. Do not rely only on an in-memory list that disappears on a crash. Store a checkpoint or produce a resumable outcome artifact.

Use transactions only where a batch invariant requires them and the deployment supports them. One enormous transaction can create unnecessary resource pressure. A migration whose records are independently transformable may use conditional writes and post-run verification instead. Transaction decisions explains that distinction.

Design recovery before writing

Ask what reversal means. If the migration only adds a derived field, removing that field may be possible, but only if no later writer has changed it. If it destroys or merges values, reversal needs preserved original data and a clear restore procedure.

Use an inverse plan with preconditions rather than a blind rollback script. A rollback should not erase legitimate edits made after the migration. Save the minimum original values and versions needed to decide whether reversal remains safe.

A backup is useful only if its restoration path has been tested and its scope fits the recovery need. Restoring the entire database to undo one field may overwrite unrelated newer work. Prefer a narrow reversible transformation where practical, and document when full recovery is the only option.

Verify the plan and final state

Run the dry mode on synthetic fixtures and inspect exact proposals. Run write mode against a copy or isolated dataset. Assert invariants, not just the count of update calls. Check preserved valid records, collision handling, malformed titles, and concurrent revision changes.

Stop the migration after a batch and resume it. Run it twice. Inject one failed write and verify reporting and continuation policy. The tool should distinguish skipped-by-precondition, rejected-by-constraint, and operational failure.

After a real authorized run, compare candidate counts and invariant queries with the plan. For slugs, request representative public URLs and check redirects or canonical behavior if identities changed. A data-level success can still leave the application serving stale cached routes.

Use AI to review the transformation

Give an assistant the pure mapping function, candidate filter, write filter, and synthetic fixtures. Ask what happens if a record changes between preview and write. Ask which original information is lost and how a rerun behaves. These questions expose concrete migration risks.

Do not provide production credentials or ask the assistant to execute an unreviewed script against live storage. A safe review can use a read-only preview with redacted fields or wholly synthetic data. Synthetic database testing develops representative failure cases.

The smallest safe migration makes its intended edits visible, refuses stale assumptions, and proves the final invariants. A count printed before writing is helpful, but it is not a sufficient preview of a consequential data transformation.

Refuse a mismatched execution target

Make the migration print and verify a safe environment identifier, database name, and migration version before writing. A dry run against a test database does not justify running the same plan against a different dataset. Bind the plan to its intended target and input revision where practical, then reject a target mismatch. Do not print credentials to establish identity. Include a test that attempts to apply a valid plan to another isolated database and confirms no records change. This check protects the reviewed transformation from a configuration mistake at execution time.

Sources

About the Author

Kamruz

Kamruz

Full Stack Developer

A passionate developer who loves creating modern web applications and sharing knowledge with the community.

More About Me

Related Articles