DEV Community

Tidy Tools
Tidy Tools

Posted on

Check a site migration's redirect map from a Google Sheet: 301s, chains and intended 410s

A migration is the one time a lot of people look at redirects. You have a spreadsheet with a few hundred or a few thousand rows, old URL and new URL, handed over by an SEO or exported from the old CMS. After launch someone has to answer: does every old URL actually go where the sheet says?

Opening 800 URLs by hand is not an option, and "it returns 200" is not the check, because a redirect that lands on the wrong page also returns 200. What I wanted is one row per old URL that says right, wrong target, chain, 302 instead of 301, or still live although we deleted it.

This is how I do it with an Apify Actor I built, Bulk URL Status Checker, and what it reported on a small test map.

The input: your sheet, as it is

Put the map in a Google Sheet with two columns (the header names are matched: Old URL / New URL, Redirect from / Redirect to, Source / Target, From / To; otherwise the first two columns are used). Share it as "Anyone with the link" and paste the link:

{
  "redirectMapUrl": "https://docs.google.com/spreadsheets/d/<your-sheet-id>/edit#gid=0"
}
Enter fullscreen mode Exit fullscreen mode

A CSV link works the same way, and so do plain lines in the input (old, new per line). If your export has relative paths, as Shopify and WordPress exports do (/old-page,/new-page), add "redirectBaseUrl": "https://shop.com".

One rule is worth knowing before you build the sheet: write 410 as the target for pages you removed on purpose. Those rows pass when the old URL answers 404 or 410, and they are flagged when it is still live.

(Honest note: the example below used the same map as inline lines. I did not run a live Google Sheet in this test, so check your first run on a small sheet.)

A real run on a 7-line test map

I wanted every outcome in one run, so the map uses deterministic test URLs from httpbin.org, plus python.org, whose http:// address really does redirect:

{
  "redirectMap": [
    "http://python.org/, https://www.python.org/",
    "https://httpbin.org/redirect-to?url=https://example.com/&status_code=301, https://example.com/",
    "https://httpbin.org/redirect/2, https://httpbin.org/get",
    "https://httpbin.org/redirect-to?url=https://example.com/&status_code=302, https://example.com/",
    "https://httpbin.org/redirect-to?url=https://example.org/&status_code=301, https://example.com/",
    "https://httpbin.org/status/410, 410",
    "https://httpbin.org/status/200, 410"
  ]
}
Enter fullscreen mode Exit fullscreen mode

The run took 2 seconds. Result, one row per line (shortened):

Old URL (shortened) Path it took Expected Verdict
http://python.org/ 301 -> 200 https://www.python.org/ https://www.python.org/ ok
/redirect-to?...example.com&status_code=301 301 -> 200 https://example.com/ https://example.com/ ok
/redirect/2 302 -> 302 -> 200 /get /get chain
/redirect-to?...example.com&status_code=302 302 -> 200 https://example.com/ https://example.com/ 302-not-301
/redirect-to?...example.org&status_code=301 301 -> 200 https://example.org/ https://example.com/ wrong-target
/status/410 410 removed on purpose ok
/status/200 200 removed on purpose still-live

Every row has matchesExpected, an issue and a redirectPath column that shows the whole chain in one cell:

{
  "inputUrl": "https://httpbin.org/redirect/2",
  "status": 200,
  "redirects": 2,
  "redirectPath": "302 https://httpbin.org/redirect/2 -> 302 https://httpbin.org/relative-redirect/1 -> 200 https://httpbin.org/get",
  "finalUrl": "https://httpbin.org/get",
  "expectedUrl": "https://httpbin.org/get",
  "matchesExpected": true,
  "issue": "chain"
}
Enter fullscreen mode Exit fullscreen mode

Note that the chain row reaches the expected page, so matchesExpected is true. The issue is what tells you it needs fixing: two hops waste crawl budget and dilute the redirect, and for SEO a 302 where you meant a 301 can keep the old URL in the index. The wrong-target row also returns 200, which is exactly why a status-code check alone misses it.

The issue labels

issue Meaning
wrong-target the old URL redirects, but not to the expected page
chain more than one hop before the final page
302-not-301 temporary redirect where a permanent one was intended
https-downgrade the redirect lands on http://
not-redirected the old URL answers 200 or an error instead of redirecting
target-broken the redirect works but the new page is 404 or 5xx
canonical-mismatch the new page's <link rel="canonical"> is not the expected URL
still-live listed as removed (410) but the page is still served

I would sort the sheet by issue, hand the rows to whoever manages redirects, and re-run the same sheet after the fix. The result is a to-do list, not a report.

Price

The Actor charges $0.001 per URL checked on the Free plan ($1 per 1,000; higher Apify plans pay a little less). The 7-line map above is 0.7 cents at list price. A 5,000-row migration is about $5 for the first full pass, and the re-runs after fixes are cheaper if you only paste the rows that were wrong. Lines that are not valid URLs and requests that time out without any response are not charged.

It also does not crawl: it requests only the URLs you give it (plus the redirects it follows) with the user agent TidyToolsLinkCheck/1.0, so allow that agent if the new site sits behind a bot filter.

What it does not do

  • It checks what a client sees: HTTP status and Location headers. It does not run JavaScript, so a redirect done in the browser (script or meta refresh) is not what it measures.
  • It cannot say whether the new page's content is a good replacement. matchesExpected compares URLs, not meaning.
  • It does not hold your sheet. Anyone-with-the-link sharing is needed so the Actor can read the sheet; unshare it afterwards if the map is sensitive.

Try it: Bulk URL Status Checker on Apify.


Disclosure: I built this Actor and earn money when people use it. The numbers come from one run on 10 October 2026 with a 7-line test map; real migrations have more edge cases, and I would like to hear about them.

Top comments (0)