← Blog

Migration

PrestaShop to WooCommerce migration: zero 404s on 60k SKUs

A 60,000-SKU PrestaShop 1.7 store had to move to WooCommerce in eight weeks, with zero broken URLs. Here is the redirect playbook we built and what nearly broke it.

Jacob Molkenboer· Founder · A Brand New Company· 3 Jun 2026· 9 min
Open leather logbook with iron shipping tags, brass key on index card with green wax seal, ink pad and stamp on ivory paper.

The PrestaShop 1.7 admin still loaded, but the official support window had closed eighteen months earlier and the hosting provider had given the client a date. Eight weeks to move the catalogue, the checkout, and 60,237 indexed URLs onto WooCommerce. The brief was one sentence: "no 404s, no ranking drops, no surprises."

That is the brief that matters in any PrestaShop to WooCommerce migration. The product data moves fine. The orders move fine. URLs are where these projects go wrong, because PrestaShop's friendly URL pattern and WooCommerce's permalink structure share almost nothing in common, and Google has years of equity in URLs that look like:

/12345-product-name.html
/category-name/12345-product-name.html
/content/7-about-us

WooCommerce, by default, wants:

/product/product-name/
/product-category/category-name/
/about-us/

Multiply that across 60,000 product pages, 1,400 category pages, 320 CMS pages, manufacturer routes, attribute combinations, and pagination, and you have roughly 70,000 redirects to write, test, and ship without anything snapping in production. Here is how we did it for this client.

The inventory comes before the migration

Week one was not a migration. It was an audit. We pulled three datasets in parallel before touching a single product row:

  • Every URL Google had indexed in the last 18 months, via Search Console's Pages export and the URL Inspection API.
  • Every URL with inbound links, via an Ahrefs export plus a fresh Screaming Frog crawl of the live site.
  • Every URL with a real hit in the last 90 days, from the Cloudflare logs the client had been keeping.

Three exports, three CSVs, deduped into one master inventory of 71,842 unique URLs the new site would have to honour. That is the number that goes in the contract, not "60,000 products." If you scope a PrestaShop migration by SKU count you will miss a third of the redirects.

The reason the inventory beats the database is that PrestaShop generates more URL variants than its product table suggests. Attribute-filtered category pages, manufacturer routes, layered-nav permalinks, and the legacy ?id_product= query strings all need to be inventoried. Crawl the live site, then crawl the logs. Do not trust the database.

The URL map is the migration

The deliverable that mattered most was a single CSV with two columns: old_url and new_url. Every row in that file is a contract with Google. We built it in three passes.

Pass one: product slugs

PrestaShop product URLs embed the numeric product ID before the slug. WooCommerce does not. The good news is that the slug itself (the human-readable part) is usually identical or close enough to match by name. The bad news is that PrestaShop allows duplicate slugs in different categories, and WooCommerce does not. We found 412 slug collisions in the catalogue.

The resolution rule we agreed with the client: keep the higher-traffic variant on the canonical slug, append the manufacturer to the loser. The mapping query against the staging WooCommerce database looked roughly like this:

SELECT
  ps.id_product,
  ps.link_rewrite AS old_slug,
  wc.post_name    AS new_slug,
  CONCAT('/', ps.id_product, '-', ps.link_rewrite, '.html') AS old_url,
  CONCAT('/product/', wc.post_name, '/') AS new_url
FROM prestashop_product_lang ps
JOIN wp_posts wc
  ON wc.post_title = ps.name
 AND wc.post_type  = 'product'
WHERE ps.id_lang = 1;

That returned 59,891 confident matches. The remaining 346 went to a spreadsheet that a human reviewed line by line. There is no shortcut for those.

Pass two: category trees

PrestaShop categories carry IDs in the URL when "Categories in URL" is on, and the category tree itself is often denormalised compared to what merchandising wants in WooCommerce. We took the live category tree, mapped it to the new tree the client had drawn for the relaunch, and produced a one-to-many map where a single old category sometimes redirected to two or three new ones.

When that happens, pick the highest-traffic new destination from analytics and accept that the secondary categories lose their direct redirect. Trying to be clever with multi-destination logic (geo-aware, query-string-aware, cookie-aware) is how migrations slip by four weeks.

Pass three: the long tail

CMS pages, manufacturer pages, supplier pages, the /best-sales and /new-products controllers, search result URLs that got indexed by accident, the old /blog/ tree from a previous platform. Each one needed an explicit rule. The unglamorous truth of a clean migration is that the last 5% of URLs eat 30% of the time.

Redirect implementation

With 71,842 rows of mapping, you do not put redirects in a WordPress plugin. We tested the popular Redirection plugin on staging first and it choked above 40,000 rules. Every request was hitting the database for a lookup against an indexed but very large table, and TTFB went from 180ms to 720ms.

The right place for these redirects is the web server. The client was on nginx in front of PHP-FPM, which made map directives the natural fit:

map_hash_max_size 262144;
map_hash_bucket_size 256;

map $request_uri $new_url {
    default                                  "";
    "/12345-classic-walnut-chair.html"       "/product/classic-walnut-chair/";
    "/home/12345-classic-walnut-chair.html"  "/product/classic-walnut-chair/";
    # 71,840 more lines
}

server {
    listen 443 ssl http2;
    server_name example.com;

    location / {
        if ($new_url) {
            return 301 $new_url;
        }
        try_files $uri $uri/ /index.php?$args;
    }
}

nginx loads that map into memory at startup. Lookups are O(1). The added latency for a redirect at scale is single-digit milliseconds. The whole map file weighed 7.4 MB. The nginx config reload took about 900ms on the production box, which we rehearsed on staging twice before doing it for real.

Takeaway

If your redirect table is larger than 10,000 rows, take it out of WordPress and put it in the web server. Plugins are fine for the editorial long tail. Bulk redirects belong above PHP.

Apache and LiteSpeed

For Apache, the equivalent is a RewriteMap with a hashed DBM file:

RewriteEngine On
RewriteMap redirects "dbm:/etc/apache2/redirects.dbm"
RewriteCond ${redirects:$1|NOT_FOUND} !NOT_FOUND
RewriteRule ^(.+)$ ${redirects:$1} [R=301,L]

You build the .dbm file from the same CSV using httxt2dbm. LiteSpeed reuses the Apache syntax. Either way, the principle is identical: lookup tables in the server, not in PHP.

The verification loop

You cannot ship 71,842 redirects on trust. The verification harness we wrote did three things every night for the two weeks before cutover:

  • Pull the master inventory from the source of truth, a Postgres table the migration team owned.
  • Hit every old URL against the staging environment with a HEAD request, expecting a 301 to the mapped target.
  • Hit every new URL with a GET, expecting a 200 and a non-empty <title>.

It is a boring script. It is also the only thing that catches the edge cases. The first run found 1,847 misses. About half were trailing-slash mismatches, a quarter were URL-encoded characters in product names (umlauts, ampersands, the lone à), and the rest were genuine mapping bugs we fixed by hand. By the fourth nightly run, we were under 50 misses, all known and triaged.

Here is the core of the checker, written as a small Node script that any operations lead can read:

import fs from "node:fs";
import { parse } from "csv-parse/sync";

const rows = parse(fs.readFileSync("redirects.csv"), { columns: true });
const base = "https://staging.example.com";
const failures = [];

for (const { old_url, new_url } of rows) {
  const res = await fetch(base + old_url, { redirect: "manual" });
  const location = res.headers.get("location");

  if (res.status !== 301 || !location?.endsWith(new_url)) {
    failures.push({
      old_url,
      expected: new_url,
      got: location,
      status: res.status,
    });
  }
}

console.log(`${failures.length} failures of ${rows.length}`);
fs.writeFileSync("failures.json", JSON.stringify(failures, null, 2));

Run it through a queue with 50 workers and 71,842 URLs check in under twenty minutes.

Cutover day

Cutover was a Tuesday at 03:00 CET. The sequence:

  1. Freeze the PrestaShop admin. No new orders, no catalogue edits.
  2. Run a final delta-sync of orders and customers into the WooCommerce database.
  3. Swap DNS to the new server. TTL had been at 300 seconds for a full week.
  4. Watch the nginx access.log on the old box drain to zero over about eight minutes.
  5. Confirm the redirect map was loaded on the new box, run a 100-URL spot check, then unfreeze checkout.

The first 404 alert came in at 04:12 CET. It was a manufacturer URL pattern we had missed (/marque/, French, on a Dutch-language site, from a theme the client had imported in 2019). Forty-three URLs total, fixed in a follow-up nginx map reload at 04:35.

Final tally one week post-launch: 13 unmapped URLs found in Search Console, all from forgotten campaign landing pages. Organic traffic at week four was within 3% of the pre-migration baseline, which for an e-commerce platform swap is the closest thing to a clean result you will see.

What we would do differently next time

Two things. First, we would build the URL inventory from the CDN logs first and the database second. The Cloudflare logs surfaced URL patterns we did not know existed: legacy mobile subdomain rewrites, an old Magento path that some Russian aggregator was still hitting, two PDFs in the upload folder that ranked for high-intent queries. The database tells you what the platform thinks exists. The logs tell you what the internet thinks exists.

Second, we would run the verification harness from day one, not from day forty. There is no reason it cannot run against staging while the mapping is still 40% complete. Watching the failure count fall from 70,000 to 50 over five weeks is more useful than discovering 1,847 issues in the final week before launch.

When we ran this migration for a homeware retailer in the Benelux, the thing that nearly killed us was the layered-navigation URLs that PrestaShop generates for filter combinations. We solved it with a regex catch-all that mapped any ?selected_filters= query string to its parent category, which Google then re-crawled and consolidated within two weeks. If you are facing a similar legacy migration, the URL inventory and the verification loop are where you should spend 80% of the budget.

Pull the Search Console Pages export this afternoon. Diff it against your sitemap.xml. The gap between those two files is the project you have not scoped yet.

Key takeaway

If your redirect table is larger than 10,000 rows, take it out of WordPress and put it in the web server. Plugins handle editorial; bulk redirects belong above PHP.

FAQ

How long does a 60k-product PrestaShop to WooCommerce migration take?

Eight to twelve weeks if you scope by URL count, not SKU count. The data move is the fast part. URL mapping, redirects, and verification fill the schedule.

Can I just use the Redirection plugin for bulk redirects?

Up to about 10,000 rules, yes. Above that, move the table to nginx or Apache. Plugin-based redirects add 200 to 500ms of latency per request at large scale.

Will I lose SEO rankings during the move?

Not if every indexed old URL returns a 301 to a relevant new URL within the first 24 hours. Expect a 5 to 15% dip in week two. Clean migrations recover within four to six weeks.

What about product images and their URLs?

Move them with the same filename and folder structure where possible. If the path must change, add image-level redirects in the same nginx map or accept the temporary re-crawl cost.

migrationlegacy sitese-commercewordpressseocase study

Building something?

Start a project