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 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
- Migration path: MariaDB 10.5 to MySQL 5.7 to MySQL 8.0 to MySQL 8.4.
- Database size: about 600 GB for a live WooCommerce store.
- Downtime target: less than one hour, with no lost orders.
- Core tools: MySQL Shell
util.dumpInstance(),util.loadDump(), async replication, andCHECKSUM TABLE. - Key lesson: for large MariaDB to MySQL migrations, a verifiable bridge path can be safer than a direct jump.
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:
- Dump the upstream with MySQL Shell
util.dumpInstance(). On the MariaDB hop, exclude user accounts withusers: false; on MySQL-to-MySQL hops, keep the native dump path. - Load into the downstream with
util.loadDump(), using parallel threads tuned to the target’s IO. UseignoreVersion: trueonloadDump()only where the version boundary requires it and after compatibility checks have been dealt with. - Wire up async replication from upstream to downstream so the upstream keeps taking writes while we move on.
- Pause the chain. Don’t open the next valve. Verify what we just moved.
- 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:
- TIMESTAMP edge cases around session
time_zone, DST boundaries, and invalid or ambiguous historical values, where dump/load or comparison queries could stringify the same intended wall-clock value differently. - Charset / collation drift, mostly around
utf8vsutf8mb4. WooCommerce stores customer-supplied strings everywhere (notes, attribute values, gift messages) and any 4-byte UTF-8 character that snuck in via emoji or non-BMP CJK exposed columns that had been silentlyutf8(i.e.,utf8mb3) in MariaDB. DECIMALandFLOATrounding at the very last digit, which sometimes survived the dump but rendered differently when re-stringified during load.
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:
- Snapshot all view definitions from the source:
SELECT TABLE_SCHEMA, TABLE_NAME, VIEW_DEFINITION, DEFINER, SECURITY_TYPE FROM information_schema.VIEWS. - Rewrite each definition: substitute the target’s known-good service user as
DEFINER, switchSQL SECURITYtoINVOKERwhere it didn’t need to run with elevated privileges (most of them). - Drop the views from the dump output before loading.
- 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:
- Before cutover, each adjacent link had been validated and catch-up tested. We treated the multi-hop path as a temporary migration pipeline, not a long-lived supported replication topology.
- We put the storefront in maintenance mode (a Cloudflare-level interstitial) so writes drained.
- Drained in-flight writes on MariaDB 10.5: waited for replication lag to hit zero and stay there, with no new traffic.
- Ran
CHECKSUM TABLEacross every table on the first hop (MariaDB 10.5 vs MySQL 5.7) and the last hop (MySQL 8.0 vs MySQL 8.4). Both matched. We had the per-hop checksum queries ready to run at every intermediate link too, but since the endpoints agreed, we didn’t need them. - We only treated checksums as authoritative after writes were quiesced and replication had reached the same executed position on the downstream. Earlier mismatches during drain were expected and were rechecked after lag reached zero.
- Re-applied any views that had drifted (one had been changed by an analytics ticket two days earlier and the migration script’s view-snapshot was stale; we caught this on the post-flip smoke test, not the checksum, and have since added a “re-snapshot views immediately before cutover” step to our runbook).
- Flipped the application’s connection string to point at MySQL 8.4.
- Took the storefront out of maintenance mode.
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
- How we made WooCommerce downtime invisible and scale effortless
- The network architecture that makes WooCommerce load before customers notice
- Self-healing WooCommerce hosting: why your hosting costs should decrease over time
- WooCommerce supporting 60k product updates per hour
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.
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.