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"
}
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"
]
}
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"
}
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
Locationheaders. 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.
matchesExpectedcompares 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)