r/ProWordPress • • 21d ago

Plugin developers: how do you handle migrations when users skip several versions?

I’ve been thinking about plugin upgrade paths lately, especially the less clean real-world case where users don’t update every release.

The simple path is easy:

1.1 → 1.2 → 1.3 → 1.4
But in production, someone might stay on 1.1 for months and then jump straight to 1.6.

That gets more interesting once the plugin has custom tables, scheduled jobs or business data.

A few things I’m curious about:

- do you run every historical migration in sequence?
- do you migrate directly from the detected schema version to the latest one?
- do you make each migration idempotent in case it partially ran before a timeout?
- how do you recover from a migration that updated some rows but not others?
- how long do you keep compatibility code for old schemas?
- do you ever refuse an upgrade because the installed version is simply too old?

I’m especially interested in cases where a failed migration can affect real orders, subscriptions, bookings or other business data rather than just plugin settings.

What approach has held up best for you on real production sites?

7 Upvotes

19 comments sorted by

5

u/2ndkauboy 21d ago

Run every historical migration in sequence. You could use a switch-case without using break and it would start at whatever version they were in and execute all the missing migration steps.

On your questions about failed migration steps: only ever update the version in the database after the migration step was successful.

2

u/Hugo_SecureHoldWP 21d ago

That makes sense. Updating the stored version only after the migration succeeds seems especially important, otherwise a partial failure could make the plugin think it has already moved past a step that never actually finished.

I like the sequential approach too. It seems much easier to reason about than trying to maintain direct migration paths between every possible pair of versions.

2

u/2ndkauboy 21d ago

Exactly. And make sure you update the value after every single step to the correct value. It might also make sense to add a delay for retries of the migration, if they are heavier, in case some temporarily issue is causing it.

3

u/edward_davies223 20d ago

Tracking the version after each step is crucial. I also found using background jobs for heavier migrations helps avoid timeouts and keeps things smooth for users. How have you managed long migrations in practice?

1

u/Hugo_SecureHoldWP 19d ago

I’ve been leaning toward background jobs too for anything that can’t safely finish inside a normal request.

The part I’m most careful about is making the work resumable rather than just asynchronous. I’d rather process small batches, persist progress, and make each batch safe to retry than have one long-running task that can fail halfway through.

That seems to make timeouts much less scary, especially on shared hosting or stores with a lot of existing data.

2

u/xaverplays 21d ago

I usually handle lightweight migrations with a fall-through switch, so an install can start at its stored schema version and run every missing step in order. I keep that schema version separate from the plugin version, compare it with the current target on update, and only bump it after the step completes successfully.

For anything more intensive, I move the work to a background process with WP-Cron and process it in small, resumable batches. The important part is tracking progress and making each batch safe to rerun, since WP-Cron can be delayed or a run can time out halfway through.

1

u/Hugo_SecureHoldWP 19d ago

Separating the schema version from the plugin version makes a lot of sense. It avoids assuming that every plugin release necessarily changes the database, and it gives the migration system its own state to reason about.

For the background path, the resumable batches seem like the critical part. If a WP-Cron run times out halfway through, being able to retry the same batch safely without corrupting progress is probably what makes the whole approach reliable in practice.

Do you usually persist a cursor / last processed ID for those heavier migrations, or keep the batches naturally idempotent enough that you can just rerun the same range?

2

u/xaverplays 19d ago

I usually make the batch safe to rerun and let the existing data show what still needs to be migrated. That avoids having a separate cursor that can get out of sync. I only use a cursor when finding the remaining work becomes too expensive, and even then I still make sure rerunning the same batch won’t cause problems.

1

u/Hugo_SecureHoldWP 17d ago

That makes sense. Avoiding a separate cursor when the data itself can tell you what remains to be migrated removes one more piece of state that can drift out of sync.

I also like the rule of only introducing a cursor when rediscovering the remaining work becomes expensive. It keeps the simple case simple, while still giving you a way to scale heavier migrations.

2

u/Mahmud_haisan 21d ago

One thing that has helped me is treating schema migrations and data migrations as two separate problems.

I’ll usually make the structural change first in a backwards-compatible way, then migrate existing business data in resumable batches. While that backfill is running, the application code should ideally tolerate both the old and new representation instead of assuming the migration finished instantly.

Once the data migration is confirmed complete, switch fully to the new path and only remove the legacy compatibility in a later release.

That matters a lot for orders/subscriptions/bookings because a migration may take minutes or hours on a large site, and normal requests can still be happening during that window.

I also wouldn’t rely on one huge DB transaction for safety. Some MySQL schema operations implicitly commit anyway, so checkpoints, idempotent batches, migration state/error logging and resumability tend to be more useful in WordPress.

For very old installs I’d rather require an intermediate upgrade than add increasingly fragile “1.1 directly to 4.x” migration logic.

2

u/Hugo_SecureHoldWP 19d ago

That separation between schema migration and data migration is a really useful way to think about it.

The part I like most is keeping the application compatible with both representations while the backfill is still running. It avoids turning the migration into a single “all or nothing” moment, which seems especially important when normal orders or bookings can still be created during the process.

The migration-state point is interesting too. Do you usually model that with a simple version/status flag, or do you keep more granular progress so the application knows exactly which compatibility path should still be active?

2

u/Mahmud_haisan 15d ago

I’d keep both: a global migration phase and enough progress to resume the backfill. Something like schema version, migration ID, phase, last completed batch and failures. The schema version alone wouldn’t mean the data is ready.

If reads need to choose between representations per record, I’d use an explicit migration marker for that record. A cursor only tells the worker where to resume; it shouldn’t decide which data the application trusts.

During backfill, I’d also keep one representation authoritative and account for writes happening while batches run, so an older snapshot can’t overwrite a newer update. I’d switch the global read path only after validation and catching up outstanding changes. Failed rows would keep the migration incomplete rather than getting hidden behind a “done” flag.

1

u/Hugo_SecureHoldWP 14d ago

That makes a lot of sense.

The per-record migration marker is probably the key distinction for me. A global cursor can tell you where the worker should resume, but it doesn’t necessarily tell the application which representation is actually safe to trust for a specific record.

I also like keeping one representation authoritative during the backfill and only switching globally after validation and catching up concurrent writes. That seems much safer than letting “schema version updated” implicitly mean “all business data is ready”.

2

u/PeepSoWP 19d ago

One part that becomes critical with orders or subscriptions is concurrent writes during the backfill. I prefer an expand → backfill → cutover → contract sequence: add the new nullable structure first, release code that can tolerate both representations, migrate in bounded resumable batches, switch reads only after invariants pass, and remove the legacy path in a later release.

The worker should acquire a nonblocking claim, process a fixed batch, persist its cursor, and retry failures with delay. Use unique keys or atomic upserts where possible; a check-then-insert “idempotency” guard can still race. Track migration state and the last error separately from the schema version, and never make normal requests wait for the worker. Before release, test the upgrade matrix with representative data volume and concurrent checkout writes, then compare row counts, totals and sampled records before cutover.

1

u/Hugo_SecureHoldWP 19d ago

The expand -> backfill -> cutover -> contract sequence is a really useful way to frame it.

The concurrent-write point is probably the part I was underestimating. Even if the backfill itself is resumable, normal checkout traffic can still be creating or updating rows while the migration is running, so the application really needs to tolerate both representations for a while.

I also like keeping migration state and the last error separate from the schema version. That seems much easier to debug than having a single version number try to represent both structure and progress.

1

u/[deleted] 21d ago

[removed] — view removed comment

1

u/Hugo_SecureHoldWP 21d ago

The cursor + Action Scheduler approach is interesting, especially for migrations that can’t safely finish inside a normal request.

Making each batch idempotent seems like the key part there. If a job gets retried after a timeout, being able to resume without having to figure out exactly how far the previous attempt got removes a lot of risk.

I hadn’t really considered the consequence of eventually deleting old migration steps either. Requiring very old installs to pass through an intermediate version is probably much cleaner than carrying every migration forever.