// open source

one-hop

Install
pip install one-hop
Runs on
Python 3.10 or later
Licence
MIT, free to use

one-hop checks a redirect map after a site move. Every old URL has to answer with one permanent redirect (301 or 308) straight to its new URL. The new URL has to answer 200. A chain, a loop, a 404 or a redirect to the wrong page fails the check.

pip install one-hop
one-hop redirects.csv --base https://example.com

Why we wrote it

We moved this site from WordPress to a static build on 23 September 2026. The redirect map listed 2,026 old addresses. A browser follows a chain of redirects without complaint. So a map can look right and still send every old URL through two or three hops. Google’s crawlers follow up to 10 hops and then stop. Every extra hop is one more request for each old URL.

So we checked every rule, on staging before the move and on the live site after it. The write-up is Moving an 8-year-old WordPress site without losing a single URL. This tool is that check, made general.

What you give it

A CSV with the old URL first and the new URL second:

old,new
/blog/old-post,/blog/new-post/
/about-us,/about/
/pricing,

An empty new URL means the page should answer 200 with no redirect. Relative URLs are read against --base.

What you get back

Against a test server with five rules:

checking 5 rules against https://example.com
1/5 OK
  loop: 1
  not_found: 2
  chain: 1
FAIL [loop] line 5: /team  redirect loop: 301 https://example.com/team -> 301 https://example.com/people/ -> /team
FAIL [not_found] line 4: /services  new URL returned 404
FAIL [not_found] line 6: /pricing  old URL returned 404, no redirect
FAIL [chain] line 3: /about-us  reaches the new URL after 2 redirects: ...

The exit code is 1 when any rule fails, so a CI job stops the release. --json writes every hop of every rule to a file.

Details that catch people out

Trailing slashes count. /about and /about/ are two URLs, and a server rule that adds the slash turns every redirect in your map into a chain.

302 and 307 are temporary redirects. one-hop fails them unless you pass --allow-temporary.

Staging sites often sit behind a password. Pass it with --auth. It goes to your staging host only, never to a host a redirect points at.

Running it in GitHub Actions

- uses: synapsereality/one-hop@v0.1.0
  with:
    csv: redirects.csv
    base: https://staging.example.com
    auth: ${{ secrets.STAGING_AUTH }}

< All open source