Summarize this article with AI
You pasted a redirect rule from the first search result, hit save, and reloaded the page. Still a 200. Or a loop. Or a 500 across the whole folder.
Classic htaccess redirect failure. The directive reads correctly, nothing shows up in the error log, and your browser is just replaying the redirect it cached last time around.
Every rule here was run on Apache 2.4.67 with mod_alias and mod_rewrite loaded. The curl output under each one is pasted straight from the terminal. You get copy-paste rules for single pages, folders, domain moves, HTTPS (including behind a CDN), www or non-www, trailing slashes, extensions and query strings, plus when a 410 beats a redirect.
There's a cheat sheet near the top, a hop-count table for canonical-host rules, and a symptom, cause and fix table for when a rule misbehaves. Testing comes before the recipes, because it's the step most tutorials skip.
htaccess redirect: the quick answer
Apache re-reads the .htaccess fresh with every request landing in its folder.1 A second after saving, you're testing it. Sending one old URL to one new URL? Here's the shortest htaccess redirect in each module:
Redirect 301 /old-page.html /new-page/
RewriteEngine On
RewriteRule ^old-page\.html$ /new-page/ [R=301,L]
On Apache 2.4.67 both give the same output, with the query string carried along for free:
$ curl -sI http://example.com/old-page.html
HTTP/1.1 301 Moved Permanently
Location: http://example.com/new-page/
$ curl -sI "http://example.com/old-page.html?utm_source=x"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/new-page/?utm_source=x
Prove it works with one command, then walk away:
curl -sI https://example.com/old-page.html # status line + Location
curl -sIL --max-redirs 10 https://example.com/old-page.html # every hop to the final 200
Three traps around that first redirect. All silent:
- No status code? Apache falls back to 302, not 301. Both
Redirect /a /bandRewriteRule ^c$ /d [R,L]land in that trap. For a permanent move, spell out301. - Forget
RewriteEngine Onand Apache silently ignores everyRewriteRule.2 No error. No log line. What ships back is a 200. - Tempted to write
^/old-page\.html$in a.htaccessRewriteRulepattern? That slash kills the match. In a per-directory context, the path handed to your pattern starts without that slash.2 Use^old-page\.html$or^/?old-page\.html$.
Permanent move gets a 301.3 If the move is temporary, 302. Need help choosing? Our 301 vs 302 redirect guide covers the call and how to verify it in Search Console.
Cheat sheet below. Jump to the section when you need the full rule:
| Job | Directive to use | Section below |
|---|---|---|
| One page to one page | Redirect or RewriteRule |
Single page |
| Folder, keep the rest of the path | RedirectMatch or RewriteRule |
Folder or section |
| Whole folder to one page | RedirectMatch |
A whole folder to one page |
| Old domain to new domain | RewriteCond %{HTTP_HOST} |
Domain to domain |
| HTTP to HTTPS | RewriteCond %{HTTPS} |
Force HTTPS |
| www or non-www | RewriteCond %{HTTP_HOST} |
www and HTTPS in one hop |
| Trailing slash | RewriteCond %{REQUEST_FILENAME} |
Trailing slash |
Drop .html or switch extensions |
RewriteRule ^(.+)\.html$ |
File extensions |
| Match a query string | RewriteCond %{QUERY_STRING} |
Query strings |
| Retire a page | Redirect gone or [G] |
410 Gone |
How to test an htaccess redirect (and why your browser lies)
Testing with curl saves an afternoon. Browsers cache 301s and replay them without checking back with the server. Your fix might be live while your tab keeps acting like nothing changed. In these tests Apache attached no Cache-Control or Expires header to its 301s. That hands the lifetime decision to the browser.
curl keeps no HTTP cache. Every request shows the file as it is right now.
Three commands cover almost every check:
# status line + Location of the first hop
curl -sI https://example.com/old-page.html
# every hop until the final 200 (or an error)
curl -sIL --max-redirs 10 https://example.com/old-page.html
# a real GET that prints the final code and the redirect URL
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://example.com/old-page.html
HEAD (-I) and GET gave me the same codes and targets on every rule I ran: 301, 302, 301 with QSD, 410, 200. HEAD by default. Only switch to GET when the page's response actually depends on the method.
Hit every variant of the old URL after the rule goes live:
http://andhttps://www.and non-www.- with and without a trailing slash
- with and without the query strings people actually arrive on (UTM parameters, product IDs)
.htaccess is re-read on every request, so you never restart the server after an edit.1 That's also why one bad line turns the whole folder into a 500 the moment you save (see the symptom table further down).
The rules in this article were tested on the Apache 2.4.67 web server (macOS 26.6.2), AllowOverride All, mod_alias and mod_rewrite loaded, with an HTTPS vhost on a self-signed certificate and a second vhost acting as a TLS-terminating proxy for the "behind a CDN" case. Outputs come from curl 8.7.1. The hostname is example.com throughout, and the Location headers show that hostname.
Redirect, RedirectMatch or RewriteRule: which to use and which one wins
Three redirect directives, three different jobs. Pick the directive by what the rule needs to see:
| Your condition | Directive |
|---|---|
| An exact path or a path prefix | Redirect (mod_alias) |
| A pattern on the URL path | RedirectMatch (mod_alias) |
| Anything that depends on the host, scheme, query string, or whether a file exists on disk | RewriteCond + RewriteRule (mod_rewrite) |
How Redirect matches
Redirect matches a prefix across whole path segments.4 Whatever sits after that prefix (query string included) gets glued onto the target. One line will carry more weight than you'd guess:
Redirect 301 /blog /news
$ curl -sI http://example.com/blog/post-1
HTTP/1.1 301 Moved Permanently
Location: http://example.com/news/post-1
$ curl -sI "http://example.com/blog?x=1"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/news?x=1
$ curl -sI http://example.com/blogging
HTTP/1.1 404 Not Found
Three consequences follow from that:
- Want
Redirectto collapse a folder into one page? It won't. Give it/old/and/new-hub/, and/old/a.htmllands on/new-hub/a.html. Want one URL catching the whole tree below? That'sRedirectMatchorRewriteRuleterritory. - Slashes must pair. A whole-domain rule like
Redirect 301 "/" "http://www.new.com"sent/page.htmltohttp://www.new.compage.html. If you write/on the left, write/(or an absolute URL that ends in/) on the right. - A same-host target inside the matched prefix loops.
Redirect 301 / /subdirectory/keeps catching the new/subdirectory/...URL and prepends again. Say you're moving the whole site into a subfolder. That's a job for mod_rewrite with a condition that spares the subfolder.
The old path must start with /. These two lines look right. They do absolutely nothing: no redirect, no 500, nothing in error_log.
Redirect 301 old-page.html /new-page/
Redirect 301 http://example.com/old-page.html /new-page/
This next one isn't a typo. It's dangerous.
Redirect 301 /x
With no destination given, mod_alias treats that single path as the target. Every URL in the directory redirects to /x, including /x itself, which loops. Nothing logs.
Which module wins
In a .htaccess context, mod_rewrite runs first and mod_alias second, no matter the line order. Line order can't change that. Swap the lines around and nothing changes:
Redirect 301 /mixed /from-alias
RewriteEngine On
RewriteRule ^mixed$ /from-rewrite [R=302,L]
$ curl -sI http://example.com/mixed
HTTP/1.1 302 Found
Location: http://example.com/from-rewrite
That 302 gives the game away. The RewriteRule answered, even with Redirect sitting on the line above. An internal RewriteRule (no R) doesn't shield a URL from Redirect either; mod_alias still matches the original URI and its 301 is what the client sees. Practical rule? Keep one URL under one module, and never bank on line order between the two.
Among Redirect lines, whichever one matches first in the file is the one that fires. Put specific lines above general ones:
Redirect 301 /shop/sale /deals
Redirect 301 /shop /store
Reverse those two and /shop/sale ends up at /store/sale, because the broader /shop prefix caught it first.
Got access to the server config? Apache's own docs say to put these directives there rather than in .htaccess.1 Inside a <VirtualHost> block, that leading slash does show up in a RewriteRule pattern. Shared hosting is .htaccess territory, and the patterns in this guide are written for it.
How to 301 redirect a single page in .htaccess
Either form handles a plain one-to-one swap. Let the rest of the file decide. Already have RewriteCond and RewriteRule lines in there? Keep the new rule in mod_rewrite so the modules don't collide. Otherwise go with Redirect. It's shorter and easier on the eye.
Redirect 301 /old-page.html /new-page/
RewriteEngine On
RewriteRule ^old-page\.html$ /new-page/ [R=301,L]
Both carry the query string. Path case matters in both.
The target can be root-relative, say /new-page/, or an absolute URL like https://new-domain.com/page/. Skip RewriteBase on a relative target and your server's filesystem path shows up in the Location header:
RewriteEngine On
RewriteRule ^old-page\.html$ new-page/ [R=301,L]
$ curl -sI http://example.com/old-page.html
HTTP/1.1 301 Moved Permanently
Location: http://example.com/private/tmp/.../apache/htdocs/new-page/
Add a leading / on the target, or set RewriteBase / once at the top of the file.
Branching on a query string parameter? The pattern can't do that on its own: RewriteRule only ever sees the path. That's in the query-string section below.
Redirect a folder or a whole section
Folder to folder, keeping the rest of the path
RedirectMatch 301 ^/blog/(.*)$ /articles/$1
RewriteEngine On
RewriteRule ^blog/(.*)$ /articles/$1 [R=301,L]
Both move every URL under /blog/ to the same path under /articles/, with the query string kept:
$ curl -sI "http://example.com/blog/post-1?x=1"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/articles/post-1?x=1
Two things that catch people out:
RedirectMatchgets the path with the leading slash. A.htaccessRewriteRulegets it without. SoRedirectMatch ^(.*)\.php$captures/aboutand its target is$1.html, whileRewriteRule ^(.*)\.php$capturesaboutand needs/$1.html. Copy a/$1target across without checking where the slash sits, and you get//inLocation.
The pattern ^/blog/(.*)$ misses the bare /blog. Once the folder's off disk, /blog serves a 404. Cover both with an optional group:
RewriteEngine On
RewriteRule ^blog(/(.*))?$ /articles/$2 [R=301,L]
A whole folder to one page
When every URL under an old section should land on a single new hub, Redirect can't do it (it appends the rest of the path). RedirectMatch or a RewriteRule collapses the folder in one line:
RedirectMatch 301 ^/old-section/ /new-hub/
$ curl -sI http://example.com/old-section/a.html
HTTP/1.1 301 Moved Permanently
Location: http://example.com/new-hub/
$ curl -sI "http://example.com/old-section/a.html?x=1"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/new-hub/?x=1
The hub has to actually cover those old pages. Otherwise, don't do it. Had real content on /old-section/post-7 and the 'replacement' is just a homepage or a tag page? Google can read that as a soft 404.5 Map old URLs to the nearest match page by page, or return 410 for the retired ones. More on when to walk that line in our soft 404 guide and our content audit workflow.
Redirect an old domain to a new domain
The rule most people reach for, a domain redirect that covers the entire site:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^(www\.)?old-domain\.com$ [NC]
RewriteRule ^(.*)$ https://new-domain.com/$1 [R=301,L]
$ curl -sI "http://old-domain.com/some/page?ref=1"
HTTP/1.1 301 Moved Permanently
Location: https://new-domain.com/some/page?ref=1
$ curl -sI "http://www.old-domain.com/some/page?ref=1"
HTTP/1.1 301 Moved Permanently
Location: https://new-domain.com/some/page?ref=1
$ curl -sI "https://www.old-domain.com/some/page?ref=1"
HTTP/1.1 301 Moved Permanently
Location: https://new-domain.com/some/page?ref=1
Both hosts, over HTTP and HTTPS, go to the same path on the new domain in one hop. Other hosts on the same server are untouched because the condition pins the host.
Two details decide whether it works:
- Case sensitivity goes away with
[NC]. The%{HTTP_HOST}variable carries the Host header verbatim, straight from the client. Drop[NC]and a request withHost: WWW.OLD-DOMAIN.COMslips right through. It never hits the new domain. - Target the final destination directly. New site at
https://www.new-domain.com/? Put that whole scheme and host straight into theRewriteRuletarget. Point hop one athttp://new-domain.com/instead, and the new box stacks its HTTPS andwww.rules on top. Your one-hop move turns into a chain.
mod_alias gives you a shorter one-liner, no host condition included:
Redirect 301 / https://new-domain.com/
Every hostname the directory serves gets redirected, including hosts you still want to run from the same codebase. Save it for folders that serve a single domain.
Don't take the shortcut of blanket-redirecting every old URL to the new homepage. There's one exception. Truly nothing on the new site matches. Google can read redirects aimed at unrelated pages as soft 404s.5 A blanket redirect to / throws away exactly the URLs you wanted to preserve. Map URLs one by one before launch. The ones with nothing to match? Let them return 410.
Adjacent trap: a full URL inside ErrorDocument quietly rewrites 404s into redirects to that URL, hiding the original status from the client. Keep it to a local error page path:
ErrorDocument 404 /404.html
Set up the new domain as its own property in Search Console. If you use URL-prefix properties, each protocol and host variant is a separate one; our Domain vs URL-prefix property guide walks through the choices.
Force HTTPS with .htaccess, including behind a CDN
The standard rule redirects every HTTP request to the same URL over HTTPS:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
$ curl -sI "http://example.com/page.html?x=1&y=2"
HTTP/1.1 301 Moved Permanently
Location: https://example.com/page.html?x=1&y=2
$ curl -sI https://example.com/page.html
HTTP/1.1 200 OK
One hop. Host, path and query string kept. Over HTTPS the condition is false, so no loop.
A classic trap sits in %{HTTP_HOST}: it includes any port the client sent. A request on :8080 turns into https://example.com:8080/.... Run the site on a non-standard port and this bites hard. Put the hostname straight into the target. Don't pull it from the variable.
Behind a CDN or load balancer, this same rule loops. The CDN handles TLS, so plain HTTP hits the origin on every request. That keeps %{HTTPS} at off forever. The browser asks for https://example.com/page.html, the CDN forwards it as HTTP to the origin, and the rule sends the visitor back to https://example.com/page.html. The browser gives up with "too many redirects."
The usual fix trusts a header the proxy sets:
RewriteEngine On
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
That works through the proxy. But the day someone talks to the origin directly over HTTPS, without the proxy adding the header, the rule loops instead. The condition that handled every case I threw at it was both at once:
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteCond %{HTTP:X-Forwarded-Proto} !https
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
Consecutive RewriteCond lines are AND, so the rule fires only when the connection isn't TLS and no proxy header says it was. One hop with or without the CDN. Trust X-Forwarded-Proto only when your CDN or load balancer is the one writing it. Not otherwise. From a direct client, it's a header anybody can send.
www, non-www and HTTPS in one hop
Pick a canonical host, write it into the rule, and send everything else there in one shot. The explicit-domain non-www to www version:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^(.*)$ https://www.example.com/$1 [L,R=301]
$ curl -sI "https://example.com/page.html?x=1"
HTTP/1.1 301 Moved Permanently
Location: https://www.example.com/page.html?x=1
The equivalent for www to non-www swaps the condition and the target:
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
RewriteRule ^(.*)$ https://example.com/$1 [L,R=301]
A generic !^www\. version matches anything that isn't a www host, but it also catches real subdomains: blog.example.com became http://www.blog.example.com/. Have subdomains worth protecting? Name the host explicitly in the rule.
Count the hops
Here's where tested rules pay off. These are the hop counts I measured from the same four starting URLs, each ending in a 200:
| Rule set | http://example.com |
http://www.example.com |
https://example.com |
https://www.example.com |
|---|---|---|---|---|
| Two rules, HTTPS first, www second | 2 | 1 | 1 | 0 |
Two rules, www first with an http:// target |
2 | 1 | 2 | 0 |
One combined rule to the final https://www. URL |
1 | 1 | 1 | 0 |
The one-liner that gave one hop everywhere:
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\.example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [L,R=301]
What [OR] does: 'this condition or the next one.' The rule then fires on plain HTTP, on a non-canonical host, or on both at once. Case gets ignored on the host match thanks to [NC]. Easy to swap in your head: [NC] handles case, [OR] handles boolean logic. Side effect of pinning the domain: any other hostname aimed at the site also winds up at www.example.com. For a canonical host, that's usually the goal.
Any variant answering 200 instead of a redirect? Expect "Duplicate without user-selected canonical" in Search Console. The fix is in our write-up on that status.
Trailing slashes and file extensions
Add or remove a trailing slash
Pick one form, match it in your canonical tags and internal links, and keep it.
Add a slash to extensionless paths that aren't real files:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !/$
RewriteRule ^(.*)$ /$1/ [L,R=301]
Real files (/style.css, /about.html) are left alone. Extensionless paths (/services) get one hop to /services/ with the query string kept. Real directories like /docs get one hop to /docs/ from the rule, no loop.
One edge to watch. The !-f test only asks whether a file is on disk, which means /missing.html quietly picks up a slash too. Rarely the behavior you'd pick. After the rule ships, hit a URL you know doesn't exist. See what the 404 actually returns.
On URLs that aren't actual directories, strip the trailing slash:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [L,R=301]
Drop the !-d and every real directory loops: your rule strips the slash, mod_dir adds it back, and curl bails out at the redirect limit. The condition is doing real work.
Remove .html (or switch .php to .html)
Switching .php to .html across a site is the easier of the two:
RedirectMatch 301 ^(.*)\.php$ $1.html
The query string stays. The $ anchor leaves .php5 and friends alone.
Going extensionless by dropping .html needs a second rule that serves the clean URL from the real file. These two rules love to fight. The naive version loops:
RewriteEngine On
RewriteRule ^(.+)\.html$ /$1 [R=301,L]
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
The redirect sends /about.html to /about; the internal rewrite then serves /about.html, which Apache runs through the file again, which fires the redirect again. Two ways to stop it:
Match the original request line, so the external redirect fires only on URLs that originally arrived with .html:
RewriteEngine On
RewriteCond %{THE_REQUEST} \s/+(.+?)\.html[\s?] [NC]
RewriteRule ^ /%1 [R=301,L]
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
Or trade L on the internal rewrite for END, which stops .htaccess from running a second time:
RewriteEngine On
RewriteRule ^(.+)\.html$ /$1 [R=301,L]
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [END]
One hop, then a clean 200. Either path. One more thing the naive version misses: ^(.+)\.html$ also matches /docs/index.html, which redirects to /docs/index. Serving index pages for directories? Either exclude index pages from the pattern, or write a condition that skips them.
Query strings: keep, drop or match them
Out of the box, both modules tack the visitor's query string onto the target. That's what you want on most page-level redirects. UTM tags and tracking parameters ride through the hop.
The confusing part is that none of Redirect, RedirectMatch or a RewriteRule pattern can see the query string at all. Ask for /product.php?id=12 and all three answered with a quiet 200:
Redirect 301 /product.php?id=12 /x
RedirectMatch 301 ^/product\.php\?id=12$ /x
RewriteRule ^product\.php\?id=12$ /x [R=301,L]
To branch on a parameter, you have to read it off RewriteCond %{QUERY_STRING}:
RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)id=12(&|$)
RewriteRule ^product\.php$ /products/blue-widget/ [R=301,L]
$ curl -sI "http://example.com/product.php?id=12"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/products/blue-widget/?id=12
$ curl -sI "http://example.com/product.php?id=123"
HTTP/1.1 200 OK
Note the anchors in the condition: (^|&)id=12(&|$) matches id=12 as a whole parameter, not inside id=123.
To clear the old query from the Location, add QSD on Apache 2.4, or stick the older trailing ? on your target:
RewriteRule ^product\.php$ /products/blue-widget/ [R=301,L,QSD]
# or
RewriteRule ^product\.php$ /products/blue-widget/? [R=301,L]
Target already carries its own query string? The visitor's string disappears unless you add QSA:
RewriteRule ^search$ /find/?source=old [R=301,L,QSA]
$ curl -sI "http://example.com/search?q=shoes"
HTTP/1.1 301 Moved Permanently
Location: http://example.com/find/?source=old&q=shoes
mod_alias has no QSA equivalent. A Redirect or RedirectMatch target that carries its own ? always drops the visitor's query.
Removed pages: return 410 instead of redirecting
A redirect is only honest when the target holds close-equivalent content. For a page that's truly gone (an event that's over, a product you've stopped selling, a post you folded into another with nothing worth salvaging), send back 410 Gone.6 The code tells clients the removal was on purpose:
Redirect gone /retired-page
RewriteEngine On
RewriteRule ^retired-page$ - [G]
Both answer 410 even if the file is still sitting on disk.
Two things set them apart:
Redirect goneis a prefix match, so it also catches/retired-page/subwith a 410. The anchoredRewriteRuleis exact.- Do not give
Redirect gonea target URL. Something likeRedirect gone /retired-page /new-page/is a syntax error. The whole folder starts answering 500, withRedirect URL not valid for this statusin the log.
410 is the redirect's sibling. Don't swap one for the other. Sitting with a stack of pages to label keep, merge, redirect or remove? Our content audit workflow walks through the calls.
RewriteRule flags that change the outcome
Those square-bracket flags at the end of a RewriteRule, or on a RewriteCond, are what tune behavior. Here are the ones that come up most.
| Flag | What it does | When you need it |
|---|---|---|
R=301 |
Return an external 301 redirect | Any permanent move |
R or R=302 |
Return a 302 | Temporary moves (A/B tests, maintenance) |
L |
End this pass through the rule set | Every external redirect rule |
END |
Stop all further rewriting | An internal rewrite you don't want re-processed |
NC |
Case-insensitive match | On the rule or on RewriteCond for host checks |
NE |
Don't URL-encode special characters in the target | Targets with # or %20 |
QSA |
Append the visitor's query string to a target that already has one | Redirects that merge parameters |
QSD |
Discard the query string | Clean the old parameters off the new URL |
G |
Return 410 Gone | Retired pages |
OR |
On a RewriteCond: this condition or the next |
Combined host and scheme rules |
Leave off the code and it's 302.7 Write R=301 on every permanent move.
L doesn't stop .htaccess; END does
[L] ends only the current pass. Inside .htaccess, the rewritten URL goes back through the whole file.7 A later rule can still catch it. On this chain, swapping [L] for [END] on the first rule is the only thing that stops the second from firing:
RewriteEngine On
RewriteRule ^old$ target.html [L]
RewriteRule ^target\.html$ /final/ [R=301,L]
$ curl -sI http://example.com/old
HTTP/1.1 301 Moved Permanently
Location: http://example.com/final/
Put [END] on the first line and the second rule won't fire.
The nastier version of a missing L hits in front of a CMS front controller. Leave L off the redirect rule and the front-controller catch-all turns the pending redirect into an internal request for index.php.
The client gets something weird: 301 status, no Location header, and the PHP page's body attached. No redirect to follow. No clear error in the log either. Put L on every redirect rule, every time.
Anchors and encoded characters
mod_rewrite URL-encodes the target by default, which breaks a few things if you don't flag it off:
#becomes%23. The browser then asks for a page literally named#sectioninstead of jumping to the anchor. AddNE.- A literal
%20in the target isn't a space.%2is read as aRewriteCondbackreference, so/new%20page/turned into/new0page/. When a host condition had capturedexampleas its second group, the same target came out as/newexample0page/. - Skip
NEand\%20gets double-encoded by Apache to%2520.
Two safe ways to put a space in the target:
RewriteRule ^space$ "/new page/" [R=301,L]
RewriteRule ^pct$ /new\%20page/ [R=301,L,NE]
mod_alias doesn't share this problem. Redirect 301 /pct /new%20page/ and Redirect 301 /faq /help/#section both pass through as written. A literal space in a Redirect target goes into the header raw (invalid), so write %20 in a Redirect.
Where redirects go in your .htaccess file (WordPress included)
Put redirects at the top of the .htaccess file in your site's root folder, above any CMS block, and end each one with L. An .htaccess file only applies to the directory it sits in and the directories beneath it, so a site-wide redirect placed in a subfolder never sees the URLs outside that folder.1
In WordPress, 'the top' is the space above the # BEGIN WordPress marker. When permalinks get flushed, WordPress rewrites the whole chunk between its # BEGIN WordPress and # END WordPress markers.8 Any rule you tucked in there can disappear, no warning given. The template:
# Canonical host and HTTPS
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} !^www\.example\.com$ [NC]
RewriteRule ^ https://www.example.com%{REQUEST_URI} [L,R=301]
# Specific page redirects (specific before general)
Redirect 301 /old-product-1 /products/product-1/
Redirect 301 /old-blog/announcements /blog/announcements/
# BEGIN WordPress
...
# END WordPress
Keep the order. Canonical host and HTTPS first, per-page redirects next, CMS block last. Can touch the server config? Shift the canonical and HTTPS rules up into <VirtualHost>, and keep the per-page redirects in .htaccess so your editors can still touch them.
htaccess redirect not working? Symptom, cause, fix
The matrix I check against when a rule misbehaves. Every row was reproduced on Apache 2.4.67.
| Symptom | Cause | Fix |
|---|---|---|
| Plain 200, no redirect at all | RewriteEngine On missing |
Add it at the top of the mod_rewrite block |
| Plain 200, no redirect at all | Leading slash in a .htaccess RewriteRule pattern (^/old-page) |
Drop the slash: ^old-page or ^/?old-page |
| Plain 200 on a query-string rule | Pattern tries to match the query string | Move the match to RewriteCond %{QUERY_STRING} |
| Plain 200 on every rule in a folder, even on a deliberate typo | AllowOverride None on that folder |
Enable overrides in httpd.conf, or move the rules up to a parent .htaccess |
| Plain 200 and no log line | Rules wrapped in <IfModule mod_rewrite.c> and the module isn't loaded |
Load mod_rewrite, or drop the guard so the 500 surfaces |
| Browser still goes to the old target after you changed the rule | Browser serving a cached 301 | Retest with curl or a fresh private window |
| Loop ("Too many redirects") | Target matches the pattern | Exclude the target: RewriteCond %{REQUEST_URI} !^/new/ |
| Loop over HTTPS behind a CDN | %{HTTPS} is always off at the origin |
Combine %{HTTPS} off with %{HTTP:X-Forwarded-Proto} !https |
| Loop on every real directory | Trailing-slash remover without !-d |
Add the condition |
Loop on /about after .html removal |
External redirect plus the internal .html rewrite re-processes |
Match on %{THE_REQUEST} or use [END] |
| 500 for every URL in the folder | A typo or unknown flag in the file | Read error_log: it names the .htaccess file and the bad directive (Invalid command 'Redirct') |
| 500 for every URL in the folder, right after adding a 410 | Redirect gone given a target |
Drop the target: Redirect gone /retired-page |
500 for every URL in the folder, Invalid command 'RewriteEngine' in the log |
mod_rewrite not loaded | Load the module (an <IfModule> guard only hides the error) |
| 302 instead of 301 | No status code written in | Add 301 (or R=301 on RewriteRule) |
// in Location |
/$1 after a capture that already carries a slash |
Check where the slash sits: a RedirectMatch capture can include it, a .htaccess RewriteRule capture never does |
| Several hops to the final URL | Split rules redirecting one step at a time | Point every old URL at the final absolute URL |
301 response with no Location header |
Missing L before a CMS front controller |
Add [L] |
Open Apache's error_log in tail while you reload the page. First move, every time. A 500 drops a log line that calls out the file and the directive at fault. When the 5xx goes beyond a bad line, our 5xx server error guide covers how it shows up in Search Console.
After the redirect: collapse chains and watch the traffic move
Stacked redirects compound fast. A chain of two page redirects (/old to /middle/, /middle/ to /final/) took 2 hops. The same two sitting on top of split HTTPS and www rules took 4.
Pointing every old URL straight at the final URL dropped it back to 1. Every hop costs the browser another round trip. Google's crawler has a cap of its own (the docs say up to 10).
Adding a new redirect? Update the older ones aimed at the URL you just moved:
Redirect 301 /old /final/
Redirect 301 /middle/ /final/
Keep the redirects in place long after the move. Old links, bookmarks and feeds keep arriving well after you've forgotten about them. For the confirmation step inside Google, see our 301 vs 302 redirect guide; for whether Googlebot has stopped requesting the old URLs, our log file analysis guide shows what to look for in the raw server logs.
Want proof the move landed where the clicks matter? Watch the Pages heatmap. In SEO Heatmap, every page gets a single row of weekly clicks. Once a redirect goes live, the old URL's row should fade over the next few weeks and the target's should pick up what it loses. If the target never collects the clicks the old URL shed, that's your first thing to inspect. Could be the wrong target, soft 404 territory, or a chain that never quite reaches it. SEO Heatmap is free for one Search Console property. Hook it up before the move and both rows are on screen from week one.
Frequently asked questions
What's the difference between a 301 and a 302 in .htaccess?
A 301 says the move is permanent to browsers and search engines. A 302 says temporary. Permanent moves get 301. A/B tests and short maintenance windows get 302. Leave the status code off a Redirect line, or write [R] without =301 on a RewriteRule, and Apache defaults to 302. On every permanent move, write 301 in.
Why isn't my htaccess redirect working?
Check the three usual suspects. RewriteEngine On missing? Every RewriteRule gets silently skipped. Leading slash in a .htaccess pattern? ^/old-page never matches (use ^old-page). Still looking broken? Your browser is probably replaying a cached 301 from a past version of the rule. Retest from curl -sI https://example.com/old-page.html. No cache in play there. A 200 from curl plus a silent error log means the rule never fired.
Can I redirect based on a query string parameter?
Yes, but not from the RewriteRule pattern or from Redirect or RedirectMatch, none of which can see the query string. Grab it with a RewriteCond %{QUERY_STRING} match instead. Example: RewriteCond %{QUERY_STRING} (^|&)id=12(&|$) then RewriteRule ^product\.php$ /products/blue-widget/ [R=301,L]. Use the (^|&)…(&|$) anchors so id=12 doesn't accidentally match inside id=123.
Where should redirect rules go in .htaccess on WordPress?
Above the # BEGIN WordPress marker, every time. When permalinks get flushed, WordPress rewrites everything between the # BEGIN WordPress and # END WordPress markers with its own block. Rules placed inside can quietly disappear. Canonical host and HTTPS rules go first. Then your specific page redirects. Then the WordPress block, down at the bottom. End each RewriteRule redirect with L.
RedirectMatch vs RewriteRule, which should I use?
Reach for RedirectMatch (that's mod_alias) when a regex redirect for a one-line folder move needs to stay readable. The moment host, scheme, query string or on-disk file checks enter the picture, switch to RewriteRule in mod_rewrite. Keep each URL under one module only. In .htaccess, mod_rewrite fires before mod_alias regardless of line order, which hides bugs. Another gotcha. That leading slash is present for RedirectMatch and gone for a .htaccess RewriteRule. Copying a /$1 target from one to the other? Watch where the slash actually ends up.
Do browsers cache 301 redirects?
Yes. Once a browser caches a 301, it replays it without a round trip, which is why your just-fixed rule can still look broken in the same tab. Apache didn't attach a Cache-Control header to any 301 in our runs. The browser gets to pick how long to keep it. Test from curl -sI (zero cache) or spin up a fresh private window.
Should I 301 removed pages or return 410?
Only redirect when the other end carries close-equivalent content. For a page that's gone for good, 410. Blanket-redirect every dead page to the homepage or a tag page and Google can call the whole batch soft 404s. For 410, write Redirect gone /retired-page in mod_alias, or RewriteRule ^retired-page$ - [G] in mod_rewrite. Never add a target URL after Redirect gone. That's a syntax error, and the whole folder starts answering 500.
How many redirects in a chain is too many?
You want one. When we tested, every old URL sent straight to the final absolute URL (final scheme, final host, final path) landed in a single hop. Stack a two-step page chain on separate HTTPS and www rules and you're at four hops, each one a fresh round trip for browsers and crawlers. Adding a new redirect? Update any older rules that pointed at the moved URL so they aim at the new final destination directly.
Moving URLs around this quarter? SEO Heatmap puts each page's weekly clicks side by side, so a few weeks after the redirects go live you can see whether the new URLs picked up the traffic or let it drop. It's free for one Search Console property.
Sources (8)
SEO Heatmap Knowledge relies on primary sources: Google's documentation, web standards, peer-reviewed research, and vendors' own pages for their pricing and features. Our editorial standards
- Apache HTTP Server Project. “Apache HTTP Server Tutorial: .htaccess files.”
- Apache HTTP Server Project. “mod_rewrite - Apache HTTP Server Version 2.4.”
- Google Search Central. “Redirects and Google Search.”
- Apache HTTP Server Project. “mod_alias - Apache HTTP Server Version 2.4.”
- Google Search Central. “Site Moves With URL Changes.”
- IETF. “RFC 9110: HTTP Semantics.”
- Apache HTTP Server Project. “RewriteRule Flags - Apache HTTP Server Version 2.4.”
- WordPress Developer Resources. “save_mod_rewrite_rules().”