MariaDB 10.5 to MySQL 8.4 Migration: How We Moved a 600 GB WooCommerce Database

By Vedanshu Jain | Published May 5, 2026 | Updated May 5, 2026

MariaDB 10.5 to MySQL 8.4 Migration: How We Moved a 600 GB WooCommerce Database

MariaDB 10.5 to MySQL 5.7 to MySQL 8.0 to MySQL 8.4. Three hops, paused at every link, verified with checksums. Here’s why we did it that way and what bit us along the route.


At a glance

Short answer: We did not migrate directly from MariaDB 10.5 to MySQL 8.4 because we did not treat MySQL 8.x consuming MariaDB 10.x changes directly as a supported or reliable path for this workload. We used MySQL 5.7 as a bridge, then moved through MySQL 8.0 to MySQL 8.4, verifying each hop with checksums before cutover.

The WooCommerce Database Migration Scenario

A WooCommerce customer of ours had been running on MariaDB 10.5 for years. The store was healthy: a single primary, a couple of read replicas, ~600 GB on disk, predictable traffic with the usual Black-Friday-sized spikes. They wanted off MariaDB and onto MySQL 8.4 for the usual reasons: ecosystem alignment, longer support runway, parity with where the WooCommerce-adjacent toolchain is heading. None of the reasons mattered as much as the constraint: the store could not lose an order, and we had a maintenance window of less than an hour. That kind of constraint is exactly why our WooCommerce infrastructure work treats database moves as production reliability projects, not one-off admin tasks.

If you go searching for “MariaDB to MySQL 8 migration,” you’ll find a clean set of guides. Frederic Descamps has written the canonical walk-through using MySQL Shell, and Oracle’s own migration post covers the basics. The recipe in those posts is roughly: dump with util.dumpInstance(), load with util.loadDump(), use ignoreVersion: true on load only when the version boundary requires it, fix up the things MySQL 8 is stricter about, take the downtime.

That recipe works at small scale. At 600 GB on a live storefront, it doesn’t.

Why Direct MariaDB 10.5 to MySQL 8.4 Migration Did Not Work

Two reasons, one obvious, one not.

The obvious reason: a logical dump-and-load of 600 GB is hours of work in each direction. Even with parallel chunked dump/load, you don’t get to take the source read-only for that long. So you need the source to keep accepting writes while the load runs, and you need a way for those writes to make it to the target before cutover. That’s replication.

The non-obvious reason: we did not treat direct MariaDB 10.5 to MySQL 8.4 replication as a supported or reliable path. MariaDB’s GTID/binlog semantics and server behavior have diverged enough that MySQL 8.x can fail on MariaDB-originated replication streams, so we validated an intermediate bridge instead of relying on direct replication. Percona has also documented a similar bridge approach after MariaDB to MySQL 8 replication broke on event-sequence mismatches.

So we couldn’t take the dump-and-replicate-the-delta shortcut directly. We needed a translator.

The Migration Path: MariaDB 10.5 to MySQL 5.7 to MySQL 8.0 to MySQL 8.4

The translator turned out to be MySQL 5.7.

MariaDB 10.5  ->  MySQL 5.7  ->  MySQL 8.0  ->  MySQL 8.4
              1              2             3
Hop Purpose Verification
MariaDB 10.5 to MySQL 5.7 Bridge out of MariaDB compatibility risk CHECKSUM TABLE plus column hashes
MySQL 5.7 to MySQL 8.0 Enter the MySQL 8 upgrade path Replication lag checks plus checksums
MySQL 8.0 to MySQL 8.4 Move to the final MySQL 8.4 target Final checksum sweep plus smoke tests

The reasoning at each hop:

1. 10.5 to 5.7. MySQL 5.7 was the compatibility bridge we could validate for this workload: file/position async replication from MariaDB 10.5 worked in our tests once MariaDB-specific features and GTID assumptions were kept out of the path. We also kept MySQL Shell user-account dumping out of the MariaDB dump path because MySQL Shell does not support dumping MariaDB user accounts.

2. 5.7 to 8.0. Once we were in MySQL territory, this was a better-understood path. Logical dump out of 5.7, load into 8.0, replicate the delta. The hard part is the data dictionary changes between 5.7 and 8.0, and MySQL Shell’s dump and load utilities know how to handle the dump/load side of that migration.

3. 8.0 to 8.4. The cleanest hop of the three. MySQL-to-MySQL replication across supported release paths is the thing the tooling and documentation are built around.

It looks like a long way around, and it is. What it gives you in exchange is that at no point do you need a tool to do something it wasn’t designed to do. The same principle shows up in other infrastructure work too, including how we think about WooCommerce failover and scaling.

How Each Migration Hop Actually Ran

The pattern at every link in the chain was the same:

  1. Dump the upstream with MySQL Shell util.dumpInstance(). On the MariaDB hop, exclude user accounts with users: false; on MySQL-to-MySQL hops, keep the native dump path.
  2. Load into the downstream with util.loadDump(), using parallel threads tuned to the target’s IO. Use ignoreVersion: true on loadDump() only where the version boundary requires it and after compatibility checks have been dealt with.
  3. Wire up async replication from upstream to downstream so the upstream keeps taking writes while we move on.
  4. Pause the chain. Don’t open the next valve. Verify what we just moved.
  5. Once verification passes, build the next link.

Step 4 was the discipline that made everything else recoverable. If a checksum mismatched at hop 1, we still had a working store on MariaDB 10.5 and one downstream replica we could rebuild. If we’d let the data flow all the way to 8.4 and then discovered drift, we’d be unwinding three hops worth of replication, and we wouldn’t know which hop introduced the drift.

How We Verified Data Integrity With Checksums

We verified integrity at every pause point. Two passes:

Fast pass: CHECKSUM TABLE.

CHECKSUM TABLE wp_woocommerce_order_items, wp_postmeta, wp_users, ... EXTENDED;

You run it on both sides, upstream and downstream, and diff the results. Using the extended form, it scans table contents and gives a fast probabilistic signal for many gross differences such as missing rows or changed values. It is not a proof of equality, and mismatches need row-level follow-up. For most tables on most hops, CHECKSUM TABLE matched and we moved on.

Slow pass: per-column hash, when CHECKSUM TABLE disagreed.

When two tables disagreed, the question became what specifically is different. CHECKSUM TABLE won’t tell you. So we’d drop to a per-column aggregate that we could compare side-by-side:

-- Identify candidate columns to investigate
SELECT
  BIT_XOR(CAST(CRC32(COALESCE(CAST(id AS CHAR),          '#NULL#')) AS UNSIGNED)) AS h_id,
  BIT_XOR(CAST(CRC32(COALESCE(CAST(order_id AS CHAR),    '#NULL#')) AS UNSIGNED)) AS h_order_id,
  BIT_XOR(CAST(CRC32(COALESCE(meta_key,                   '#NULL#')) AS UNSIGNED)) AS h_meta_key,
  BIT_XOR(CAST(CRC32(COALESCE(meta_value,                 '#NULL#')) AS UNSIGNED)) AS h_meta_value
FROM wp_woocommerce_order_itemmeta;

Any differing h_* value identifies a candidate column to investigate. Because this is still a non-cryptographic aggregate, it can theoretically collide or cancel paired differences. We used it to narrow the search, then confirmed by primary-key ranges and row-level comparison.

The mismatches we found, and the ones you should expect, were almost never “data loss” in the dramatic sense. They were:

The lesson isn’t “checksums are a problem.” It’s that you want to know which column drifted before you start theorizing. The two-pass design (cheap full-table, then expensive per-column on the small set of mismatches) made the verification time manageable instead of catastrophic.

How We Handled MySQL Views, DEFINER, and SQL SECURITY Issues

Views are where the dump-and-load story breaks down on every database migration anyone has ever done, and ours was no exception.

The specific failure mode here was DEFINER and SQL SECURITY. A view created on MariaDB 10.5 carries metadata like:

CREATE
  ALGORITHM = UNDEFINED
  DEFINER = `legacy_admin`@`10.0.%`
  SQL SECURITY DEFINER
VIEW v_active_subscriptions AS
SELECT ...

When util.dumpInstance() writes that out, it preserves the DEFINER. When util.loadDump() recreates it on the target, the user [email protected].% may not exist there, and even if you’ve recreated users, the host pattern often doesn’t match the new infrastructure’s IP space.

With sufficient privileges, the view may be created with an orphan DEFINER, often with only a warning. The failure then appears later when SQL SECURITY DEFINER tries to execute as a user that does not exist or cannot be resolved.

We pulled views out of the dump+load path entirely. The script:

  1. Snapshot all view definitions from the source: SELECT TABLE_SCHEMA, TABLE_NAME, VIEW_DEFINITION, DEFINER, SECURITY_TYPE FROM information_schema.VIEWS.
  2. Rewrite each definition: substitute the target’s known-good service user as DEFINER, switch SQL SECURITY to INVOKER where it didn’t need to run with elevated privileges (most of them).
  3. Drop the views from the dump output before loading.
  4. Apply the rewritten views after the load completed and tables were verified.

This is also a happier separation conceptually: tables are data, views are DDL. Migrate them on different tracks. They don’t share a clock.

The Cutover

The customer chose a 15-60 minute maintenance window. We expected to use most of it on a final checksum sweep, and we did.

The sequence on the day:

The dominant cost in the window was the checksum re-verification at cutover, not the cutover itself. The flip is a connection-string change. The verification is what gives you the right to flip. The same bias toward verification shows up in our WooCommerce network architecture: fast paths matter, but only when the system can prove it is still correct.

What We’d Do Again, and What We’d Do Differently

We’d do the chain again. Three hops sounds excessive on the whiteboard. In practice it meant every individual transition was a thing the tools were designed for. No bespoke replication shims, no patched binlog parsers, no surprises from MySQL 8 trying to consume MariaDB events. The chain is reproducible. We have it written down as four pipelines and it’ll run the same way for the next customer.

We’d checksum endpoints and have per-hop queries ready. Checking the first and last hop gave us fast confirmation. Having the intermediate queries pre-built meant we could drill into any hop immediately if the endpoints had disagreed. The cost of catching drift at hop 1 is one rebuilt link. The cost of catching it at the cutover is the cutover.

We’d snapshot views at cutover, not earlier. Pulling views out of the dump+load is right; pulling them out too early leaves you with a stale snapshot. Snapshot, rewrite, and apply at cutover, not at the start of the project.

We wouldn’t bother trying direct 10.5 to 8.4 replication first. We did try, briefly, and it failed in the way documented by others. The hour we spent on it was an hour better spent on the chain.


The shape of this kind of project is always: how do you move a large amount of mutating data between two systems that don’t speak the same dialect, with a downtime budget measured in minutes, on a store that is taking real money the whole time. The tools, MySQL Shell, native replication, CHECKSUM TABLE, are well-documented in isolation. The work is composing them into a chain that’s verifiable at every link.

Written from a production WooCommerce migration where a 600 GB MariaDB 10.5 database was moved to MySQL 8.4 with no lost orders and a sub-hour maintenance window.

FAQ

What is util.dumpInstance()?

util.dumpInstance() is a MySQL Shell utility that exports a MySQL server instance to local files or object storage. The dump is later imported with util.loadDump().

Can MySQL 8.4 replicate directly from MariaDB 10.5?

We would not treat it as a supported or reliable production migration path. MariaDB binlog, GTID, collation, and server-behavior differences can make direct MariaDB 10.x to MySQL 8.x replication fail or drift, so we used a bridge path.

Why use MySQL 5.7 as a bridge?

MySQL 5.7 gave us a compatibility bridge we could validate before moving into the MySQL 8 upgrade path. It let the source keep accepting writes while we moved and verified data hop by hop.

Is ignoreVersion a dumpInstance() option?

No. ignoreVersion is a util.loadDump() option. It tells MySQL Shell to proceed with loading a dump across a non-matching or non-consecutive version boundary after compatibility issues have been addressed.

How do you verify a large WooCommerce database migration?

Use table checksums first, then column-level hashes for mismatches, and repeat verification after writes are drained and replication has reached the same executed position on the downstream.

Related reading


If you are planning a MariaDB to MySQL migration for a revenue-critical WooCommerce store, contact us before you choose the migration path. The order of de-risking matters as much as the tooling: reach out.

Back to Blog

Built by ex-WooCommerce core developers

We're ex-WooCommerce core developers and ex-Google/Meta engineers who've scaled systems handling millions of requests per minute. We built the parts of WooCommerce that matter in production: performance, payments, and reliability. That's why we can operate your store end-to-end, not just host it.

Product

Company

Case Studies

Privacy Policy | Terms of Service

© 2026 Urumi. All Rights Reserved