Open the document root of any WordPress site running on Apache and there is a file called .htaccess in it. No extension, hidden by default in most FTP clients, and the only file on the server that can take the entire site down with one bad line. Almost nobody who owns one has read it.
What is usually in there is sediment. WordPress writes nine directives into it the first time you save a permalink structure. A cache plugin adds thirty lines of expiry headers. A security plugin adds a chunk labelled with its own name. Somebody pasted six rules from a forum thread in 2019 to harden the site, and nobody has touched them since. Then the host moved you from mod_php to PHP-FPM, one php_value line stopped being legal, and every URL started returning a 500.
The file gives you no help while any of this is happening. Apache has two responses to a directive it dislikes: ignore it in silence, or refuse the whole request with a 500. There is no warning level in between, and nothing anywhere tells you that a rule you wrote can never be reached.
What the file actually is
Apache’s real configuration lives in files only the host can edit. An access file hands a slice of that configuration to whoever can write into a directory, and the size of the slice is set by AllowOverride in the virtual host: FileInfo covers the rewrite engine, Redirect, Header and ErrorDocument, Limit covers the old access directives, AuthConfig covers Require and password protection, Options covers Options and the PHP module settings. A directive outside your slice is not ignored, it is a 500.
Below is the reading aid. Paste your file in, and every line gets a sentence beside it, generated blocks get named and folded, and the problems are listed worst first. Two switches change the verdicts, because the same line is fine or fatal depending on the server: PHP as an Apache module or through FPM, and Apache 2.4 or 2.2. It all happens in your browser. There is no upload, and the file never leaves the page.
htaccess explainer
Paste an .htaccess you inherited and read it back in plain sentences. Every directive gets a line of English beside it, the blocks a plugin generated are named and folded away, the mistakes are listed worst first, and you can walk a real URL through the file to see which line acts on it and in what order. Everything is worked out in this browser tab, nothing is uploaded and nothing is fetched.
A line ending in a backslash is joined with the next one, quoted arguments are kept whole, and the first 1200 lines are read.
A block between BEGIN and END markers belongs to whatever wrote it, and is replaced wholesale the next time that plugin saves. Fixing anything inside one of those lasts until then.
The rest of this is htaccess explained the way the server reads it: top to bottom, twice, with no commentary on anything it skipped.
The price of a file read on every request
Apache does not read the file once at startup, and it does not read only the one in the document root. For a request to /wp-content/uploads/2026/05/photo.jpg it looks for an access file in every directory along the path, from the filesystem root down to the folder holding the file. Each of those is a filesystem lookup, each file found is parsed from scratch, and nothing is cached between requests, because the point of the mechanism is that an edit takes effect on the next hit without anybody restarting anything.
The same rules in the virtual host are parsed once when Apache starts and then cost nothing. That is why Apache’s own documentation tells you to avoid access files whenever you can edit the server configuration, and why a good number of managed hosts either set AllowOverride None (in which case Apache does not even look for the file) or run nginx, which has no equivalent at all. LiteSpeed does read it, which is why a LiteSpeed cache plugin writes to it happily.
None of this makes an access file the reason your site feels slow. A few stat calls are nothing next to an uncached query or a 4 MB hero image, and the order you investigate a slow site in matters more than any single fact, which is what the diagnostic order for a slow WordPress site is about. It does mean that 300 lines of pasted snippets are pure overhead, paid on every image, every stylesheet and every page view.
The WordPress block is a fallback, not a router
Here is what WordPress writes, unchanged for years, on a single site install with pretty permalinks:
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
The IfModule wrapper stops the file killing a server that has no mod_rewrite. The E=HTTP_AUTHORIZATION line copies the Authorization header into an environment variable, because under CGI or FPM it does not otherwise reach PHP, and application passwords on the REST API depend on it. RewriteBase supplies the prefix for any substitution written as a relative path. The ^index.php$ rule stops the front controller testing itself.
Then the three lines that do the work: if the request does not name a real file, and does not name a real directory, hand it to index.php. That is the entire contribution of Apache to WordPress routing. Which post loads, which archive, which taxonomy term, all of that happens in PHP against REQUEST_URI, using the rewrite rules stored in the database that get rebuilt when you flush permalinks. Those rules never appear in this file.
That split is worth holding onto. A permalink returning 404 while the rest of the site works is a PHP routing problem, not an access file problem. A missing image is the opposite: the front controller only fires when the file is not on disk, so a broken image is a question about the file, the path and the permissions first, which the five layers behind images that will not show takes in order.
Everything between the two markers is disposable. save_mod_rewrite_rules() in wp-admin/includes/misc.php calls insert_with_markers(), which keeps every line before # BEGIN WordPress and every line after # END WordPress and replaces the middle wholesale. A rule you add inside survives until the next time somebody presses save on the permalink settings. Put your rules above the block, or use the mod_rewrite_rules filter, which is the supported way to change what gets written. On multisite the function bails out before writing anything, which is why network installs print the rules on screen and expect you to paste them in.
The flags, and the second pass
[L] is the flag everyone thinks they understand. In the server configuration it does mean last: rewriting stops. In an access file it is narrower. Rewriting stops for this pass, and then, if the URL actually changed, Apache hands it back to the URL mapping machinery, walks the directories again and runs the whole file again from the top. The catch-all in the WordPress block does not end the request. It restarts it.
On that second pass the request is /index.php, a real file, so the conditions fail and the rule does not fire again. Everything below the block still runs, but it now sees index.php rather than the path the visitor typed. That is why a redirect added at the bottom of the file appears to be ignored: it is not ignored, it just never sees the URL it was written for. One line quietly eating everything below it is the most common thing an inherited file has wrong with it.
[END], added in Apache 2.3.9, is the flag that means what people expect [L] to mean: stop rewriting, start no further pass. If the passes never settle, LimitInternalRecursion stops them at ten and Apache returns a 500. That is what a rewrite loop looks like from the outside.
The other trap has nothing to do with flags. A RewriteCond binds to the next RewriteRule and nothing else. Two conditions and two rules underneath them are not a group of four: the conditions apply to the first rule, and the second rule is unconditional. Files written by hand are full of this, usually as two rules that look like a matched pair because they were copied one from the other.
Redirect and RewriteRule are not two spellings of the same thing
Redirect and RedirectMatch come from mod_alias, an older and much simpler module. Redirect matches a prefix and appends whatever followed it, so Redirect 301 /blog /news sends /blog/hello/ to /news/hello/. RedirectMatch takes a regular expression and appends nothing, so anything carried over has to be captured and written into the target. Both always produce a redirect the browser can see, and both need a target that is a full URL or a path starting with a slash. A relative target produces a Location header the browser resolves in ways you did not intend.
RewriteRule does all of that and more: [R=301] for an external redirect, no flag at all for an internal rewrite the visitor never sees, [F] for a 403, [G] for a 410, [QSA] to keep the query string. It can also test what Redirect cannot see, which is what RewriteCond is for: the query string, the host, the method, a header.
Mixing the two is where it gets confusing, because the file is not evaluated in the order it is written. Inside an access file the rewrite engine gets the request before mod_alias does, whatever the line numbers say. Put a Redirect above the WordPress block and the front controller still rewrites first, the request restarts as /index.php, and your redirect is compared against that. On a WordPress site, write redirects as RewriteRule with [R=301,L] above the block. While you are testing use 302, because browsers cache a 301 hard and you will spend an afternoon debugging your own cache.
The snippets that stopped doing anything
Most inherited files carry a security section pasted from a forum. Some of it earns its place. A good deal of it has been decorative for a decade.
Options -Indexesworks. Plenty of default configurations still switch directory listings on for the document root. Keep this one.- Protecting
.htaccessfrom being served is redundant. Stock Apache configuration already denies any file whose name starts with.ht. That block is a second lock on a locked door. <Limit GET POST>means the opposite of what people think.Limitrestricts only the methods you name. Everything you did not name stays unrestricted. The directive that means “everything except these” isLimitExcept.Order allow,denyis Apache 2.2 syntax. On 2.4 it survives only while the compatibility module is loaded; without it the line is a fatal unknown command. Mixing those directives with 2.4’sRequirein one context is not supported, and the result is not what either half promises.Header set X-XSS-Protection "1; mode=block"talks to browsers that no longer exist. Chrome removed its XSS auditor in 2019, Edge followed, Firefox never shipped one.Header setis notHeader always set. Plainsetonly applies to successful responses, so your security headers quietly vanish from every 404 and every 500.alwaysis the one that covers error responses too.- User agent blocklists are theatre. The user agent is a string the client chooses. Referer rules against hotlinking do work on ordinary browsers, but they are easy to write in a way that also blocks Google Images, which stopping hotlinking without breaking image search goes through.
php_valueandphp_flagonly exist under mod_php. Under FPM or CGI they are an unknown command and every request returns 500. Those settings belong inphp.ini, a.user.ini, or in the constants that earn their place in wp-config.php.
If you want one header in that section to be worth the bytes, make it Content-Security-Policy, set with Header always set. It is the one that actually stops a class of attack rather than announcing an intention, and it is also the one most likely to break your own site if you write it blind, which is the subject of the header nobody dares switch on.
Whether your server reads the file at all
Before you debug a single rule, find out whether anything is reading them. The test takes ten seconds and no shell access: put one line of nonsense at the top of the file, something like ThisIsNotADirective, save, and load the site. A 500 on every URL means the file is read and parsed, and you can take the line out again. A site that carries on as before means it is not, and every rule in there has been decoration.
Two things can muddy the result. AllowOverride may be set to a narrow list, so nonsense is fatal while the directives you actually care about are still refused one by one, and Apache 2.4 has a Nonfatal option that downgrades exactly this error to a log line. If you can read the error log, that is where the truth is: a rejected directive is logged with the file path and the line number. On nginx the answer is permanent, because the file is never read at all, and plenty of WordPress sites carry an .htaccess that has done nothing for years.
What the explainer deliberately does not do
The tool reads the first 1200 lines and says so if there are more. Patterns over 400 characters, and patterns JavaScript cannot compile, are stepped over rather than guessed at. Every module named in the file is treated as loaded, so a negated IfModule is skipped. The method is GET. Filesystem tests come from the checkbox marked as a real file on disk rather than from a disk, and that checkbox is the whole game for a front controller: off, and the request falls through to index.php; on, and the file is served without any rewriting.
A condition testing something no browser tab can know, a user agent or a cookie, is taken as matching and the hop says so. mod_alias redirects are walked in file order with a note that the real server does not run them there. Six internal passes and eight external hops, then it calls the result a loop.
What it is not is Apache. It does not repair, rewrite or generate a file, and it will not tell you what a specific build with a specific set of loaded modules is going to do at three in the morning. When you need that level of certainty and you have access to the virtual host, turn on the rewrite log with LogLevel alert rewrite:trace3, make one request, and read the engine’s own account of every rule it tried. Then turn it off again, because it is loud.
A file worth reading once
The honest summary of .htaccess is that it is a workaround with good manners. It exists because shared hosting needed a way to let customers change configuration without letting them into the configuration, and it pays for that with a filesystem walk and a parse on every request, forever. Anything in it that could live in the server configuration should, and where you cannot do that, keep the file short enough to read in one screen.
Most of the damage in an inherited file is not a wrong rule. It is a right rule in the wrong place: below the front controller, after an unconditional catch-all, or attached to a condition that binds to a different line than the author assumed. Those mistakes are invisible in the file and in the browser, because Apache never reports what it decided not to do. Walking one real URL through the file usually teaches more than reading it does.
So spend twenty minutes on it once. Read the findings, delete the blocks left by plugins you removed two years ago, move anything you wrote out of the WordPress markers, and check that the header you thought was protecting the site is set with always. Then leave a comment at the top with the date and your initials, so the next person to open the file, probably you, can tell which parts were on purpose.