301, 302, 307, 308: Picking the Right Redirect Without Guessing
Published
Four status codes, two questions. Is the move permanent, and does the HTTP method need to survive the hop? Answer both and the right code falls out. Includes copy-paste Apache and nginx rules, plus the chain limit that quietly breaks migrations.
Somewhere in every codebase there is a redirect that someone picked by vibes. It works, traffic flows, nobody looks again. Then a form submission turns into a GET, a POST body vanishes, and you spend an afternoon in the network tab. The four common redirect codes are not interchangeable, and the rule for choosing between them fits in two questions.
The two questions
| method preserved | method may become GET
--------------+------------------+----------------------
permanent | 308 | 301
temporary | 307 | 302
That is the entire decision. Is this move permanent? picks the row. Does the HTTP method and body need to survive? picks the column.
The history is worth thirty seconds, because it explains why the grid looks redundant. 301 and 302 were specified loosely, and real browsers converged on rewriting the request to GET when following them, whatever the spec's intent was. Rather than break a web's worth of deployed behavior, the standards added 307 and 308 with the method-preserving requirement written in explicitly. So the modern codes are the strict ones, and the old codes are the ones with the historical quirk baked in.
Why it matters in practice
For a plain page move, GET to GET, 301 versus 308 makes no practical difference. Where it bites is anything that is not a GET:
POST /api/orders -> 301 to /v2/orders
Browser follows as: GET /v2/orders (body gone)
POST /api/orders -> 308 to /v2/orders
Browser follows as: POST /v2/orders (body intact)
If you are redirecting an API endpoint, a form target, or anything behind a fetch call with a method other than GET, use 307 or 308. If you use 301 there, the request arrives stripped of its body and your handler sees an empty GET, which produces some of the most confusing bug reports you will ever read.
What search engines take from each
Google groups them the way you would hope:
- 301 and 308 are permanent signals. Google will move indexing to the destination and treat it as the canonical URL.
- 302 and 307 are temporary signals. Google keeps the original URL as the canonical one and expects the redirect to go away.
Two nuances that get overstated online. First, Google is not naive: a "temporary" redirect left in place for months eventually gets treated as permanent, because the observed behavior contradicts the label. Second, the old folklore that 302s "leak link equity" is not how Google describes it today; both kinds of redirect pass signals, and the difference is which URL ends up canonical. None of which is an argument for being sloppy. Use the code that describes reality, and you never have to wonder what a crawler inferred.
Caching, the part people forget
Permanent redirects are cacheable by default, and browsers cache them aggressively. A 301 can stick in a user's browser long after you have changed your mind, and there is no way to reach out and clear it. This is the single best reason to reach for 302 or 307 when you are not certain.
You can put a leash on it with an explicit header:
HTTP/1.1 301 Moved Permanently
Location: https://example.com/new-path
Cache-Control: max-age=3600
Now the redirect is cached for an hour rather than indefinitely. When you are mid-migration and still finding mistakes, that hour is cheap insurance.
Apache rules
For a single path, Redirect from mod_alias is the simplest thing that works:
Redirect 301 /old-page /new-page
Redirect 308 /api/orders /api/v2/orders
For patterns, you want mod_rewrite. Force HTTPS and strip www in one pass:
RewriteEngine On
# HTTPS, using the modern HTTPS variable
RewriteCond %{HTTPS} !=on
RewriteRule ^(.*)$ https://%{HTTP_HOST}/$1 [R=301,L]
# strip www
RewriteCond %{HTTP_HOST} ^www\.(.+)$ [NC]
RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
A gotcha with RewriteRule: the flag is [R=301]. A bare [R] defaults to 302. Every "why is my permanent redirect showing up as temporary" thread ends here.
Note also that .htaccess is Apache-only. On nginx these directives do nothing at all, silently. If you are not sure which one you are on, curl -I and read the Server header before you write a line.
Our .htaccess generator assembles redirect blocks, HTTPS enforcement, and cache rules with the right flags already set, which saves a trip to the documentation for the exact RewriteCond syntax.
The nginx equivalent
server {
listen 443 ssl;
server_name example.com;
location = /old-page {
return 301 /new-page;
}
location = /api/orders {
return 308 /api/v2/orders;
}
}
# HTTPS + www in one server block
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
return is faster than rewrite here and reads more clearly. Reach for rewrite only when you actually need a captured group.
Chains: the migration killer
Every hop is a round trip, and hops accumulate quietly during migrations. You move a site, then reorganize a section, then enforce HTTPS, and now:
http://www.example.com/old
-> 301 https://www.example.com/old
-> 301 https://example.com/old
-> 301 https://example.com/new
-> 200
Three round trips before a byte of content. On a slow mobile connection that is real, measurable delay for every visitor arriving on an old link.
Googlebot follows a limited number of hops per URL before it gives up and treats the chain as an error, so a long chain can mean the destination is simply never reached. Collapse chains so every old URL points directly at its final destination in one hop. Order your rules so the combined case is handled first, rather than letting each rule fire in sequence.
Auditing them is easy:
curl -sIL http://www.example.com/old \
| grep -i '^\(HTTP/\|location:\)'
Every HTTP/ line printed is one hop. If you see more than two, there is work to do.
Two things that are not redirects
<meta http-equiv="refresh"> and window.location = ... both move the user, and both are worse than an HTTP redirect. They need the page to download and parse first, they are slower, they interact badly with the back button, and they give crawlers a much weaker signal. Google can follow them, but treating them as equivalent to a 301 is wishful thinking. If you control the server, redirect at the server.
The cheat sheet
- Page moved for good, GET request: 301.
- Endpoint moved for good, non-GET: 308.
- Temporary detour, maintenance page, A/B test, geo bounce: 302, or 307 if the method matters.
- Not sure yet: temporary, plus a short
Cache-Control. You can always promote it later. - Always: one hop, and verify with
curl -sILrather than trusting the config.