2026年11月12日
2026年11月12日
Fix Mass 404 Errors in WordPress: Permalink Repair and Bulk 301s
This article is a localized edition of the Japanese original. Read the Japanese original
When WordPress suddenly returns a large number of 404s, the fix depends on whether every page is a 404 or only certain old URLs are. The first case is a problem with URL rewrite rules and is often fixed in minutes. In the second case you build a table that maps old URLs to new ones and set up 301 redirects in bulk. This article shows how to tell the two cases apart, how to process hundreds of 404s at once, and how to verify the result.
First, identify which kind of 404 you have
Check which of these two describes your site.
- The home page loads, but every single post, page and category returns a 404
- Most pages work, and only old URLs reached from search engines or external links return a 404
In the first case, the mechanism that hands requests to WordPress (.htaccess or the Nginx configuration) is broken. It often happens right after a site migration, a server configuration change, or a change to the permalink settings. The second case is caused by changed slugs, a new permalink structure, deleted or merged posts, or a move to another domain or directory.
When every page is a 404: repair the rewrite rules
Re-save the permalinks from the admin screen
Open Settings → Permalinks (設定 → パーマリンク in the Japanese UI) and click "Save Changes" without changing anything. This rebuilds WordPress's internal rewrite rules, and on Apache it also updates .htaccess if the file is writable. WP-CLI can do the same.
# Rebuild the rewrite rules and also write .htaccess
wp rewrite flush --hard
# Current permalink structure
wp option get permalink_structure
Apache: check .htaccess and AllowOverride
If re-saving does not help, check that the .htaccess file in the document root contains this standard block.
# BEGIN WordPress
<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
If the block is correct and you still get 404s, suspect that Apache is not reading .htaccess at all. If you installed Apache yourself on a VPS and the directory is still set to AllowOverride None, .htaccess is ignored. And if mod_rewrite is disabled, everything inside <IfModule> is skipped without any error.
# Debian/Ubuntu: enable mod_rewrite
sudo a2enmod rewrite
# After changing to AllowOverride All inside <Directory /var/www/html> of the VirtualHost
sudo apachectl configtest && sudo systemctl reload apache2
Nginx: check try_files
Nginx does not read .htaccess, so re-saving the permalinks will not fix it. Check that the server block contains this setting.
location / {
try_files $uri $uri/ /index.php?$args;
}
Without this line, Nginx returns a 404 for every path that does not exist as a file (that is, every WordPress permalink). After the change, check the syntax with sudo nginx -t and then reload.
When only a custom post type returns 404
If only a specific custom post type or taxonomy returns 404, the rewrite rules were probably not updated after it was registered. Whenever a plugin or theme adds or changes a custom post type, re-save the permalinks once. In your own plugin, the standard approach is to call register_post_type() and then flush_rewrite_rules() on activation. Do not call flush_rewrite_rules() on every init, because it is expensive.
404s on specific URLs: list the affected URLs first
To fix 404s on old URLs, you first need an accurate list of which URLs return 404. There are two sources.
- Google Search Console: under Indexing → Pages, open the URLs whose reason is "Not found (404)" and export the list (screen layout as of October 2026). This shows the URLs search engines know about, with some delay.
- Server access logs: these show URLs that real visitors requested and got a 404 for. Start with the most frequent ones.
# URLs that returned 404, sorted by count (combined log format)
awk '$9 == 404 {print $7}' /var/log/nginx/access.log \
| sort | uniq -c | sort -rn | head -100 > /tmp/404-top.txt
Logs also contain a lot of attack scans, such as requests for .env or the admin paths of other CMSs. Those URLs never existed, so do not redirect them. Only URLs of pages that really existed need fixing.
Build a mapping table and decide where each URL goes
From the list, create a CSV table that maps old URLs to new ones. This table drives both the redirect configuration and the verification. Write every path relative to the site root (starting with /).
source,target
/2023/05/12/old-article/,/old-article/
/category/news/old-post/,/news-renewal/
/old-service/,/services/
Use these rules to decide where each URL should go.
- If there is a page with the same content or a successor page, 301-redirect to it
- If a page was deleted and has no replacement, leave it as a 404 or return 410. Sending everything to an unrelated home page tends to be treated as a soft 404 by search engines and is unhelpful for visitors
- Avoid redirect chains, where the destination redirects again. Check that no target in the table appears as the source of another row
Note that WordPress core records old slugs when you change a post's slug and redirects them to the new URL. If only the slug of the same post changed, the old URL may already redirect without any extra rule. Before building the table, open the old URLs and check whether they already redirect; this can cut the work down.
Set up 301 redirects in bulk
When a pattern covers the change
For a regular change, such as switching permalinks from a date-based structure to post name only, one regular expression can redirect everything. Put it in .htaccess before # BEGIN WordPress. Anything inside the WordPress block is erased when the permalinks are re-saved.
# 301 redirect /2023/05/12/slug/ → /slug/
RewriteEngine On
RewriteRule ^[0-9]{4}/[0-9]{2}/[0-9]{2}/([^/]+)/?$ /$1/ [R=301,L]
On Nginx, put it in the server block.
# 301 redirect /2023/05/12/slug/ → /slug/
rewrite ^/[0-9]{4}/[0-9]{2}/[0-9]{2}/([^/]+)/?$ /$1/ permanent;
When you need a per-URL mapping table
For hundreds of redirects with no pattern, generate the configuration from the table instead of writing each rule by hand. On Apache, you can build RewriteRule lines from the CSV. A CSV saved from Excel may have CRLF line endings, so strip them with tr -d '\r' before reading.
# Generate .htaccess lines from the CSV (first line is the header)
tail -n +2 redirects.csv | tr -d '\r' | while IFS=, read -r from to; do
from_re=$(printf '%s' "${from#/}" | sed 's/[.[\*^$()+?{|]/\\&/g')
printf 'RewriteRule ^%s/?$ %s [R=301,L]\n' "${from_re%/}" "$to"
done > redirects.htaccess
The example table above produces these lines. Each rule matches with or without the trailing slash.
RewriteRule ^2023/05/12/old-article/?$ /old-article/ [R=301,L]
RewriteRule ^category/news/old-post/?$ /news-renewal/ [R=301,L]
RewriteRule ^old-service/?$ /services/ [R=301,L]
Review the generated redirects.htaccess by eye, then paste it into .htaccess before # BEGIN WordPress, right after a RewriteEngine On line. Make a copy of .htaccess before you start so you can roll back immediately if the site breaks.
On Nginx, map keeps a large number of redirects manageable. Put map in the http context (outside the server block).
# /etc/nginx/conf.d/redirects.conf (http context)
map $uri $redirect_to {
default "";
include /etc/nginx/redirects.map;
}
# /etc/nginx/redirects.map (one "old-path new-path;" per line)
/old-service/ /services/;
/category/news/old-post/ /news-renewal/;
# Inside the server block
if ($redirect_to) {
return 301 $redirect_to;
}
map keys are exact matches, so a URL with and without a trailing slash needs two separate keys. After the change, check the syntax with sudo nginx -t and then reload.
If you cannot edit the server configuration, use a redirect management plugin. For example, Redirection can import CSV files, so you can load your table as is, and it also records how often each redirect is hit.
Verify every redirect after setup
Once the rules are in place, check every row of the table: the old URL must return 301, point to the right destination, and the destination must return 200.
One pitfall: curl's %{redirect_url} returns a full URL such as https://example.com/old-article/. If you compare it directly with the table's target (/old-article/), it never matches, even when the redirect is correct. The script below prefixes the target with the site URL and uses that full URL as the expected value.
base="https://example.com" # Your site URL (no trailing /)
tail -n +2 redirects.csv | tr -d '\r' | while IFS=, read -r from to; do
# If target is a full URL use it as is; if it is a path, prefix the site URL
case "$to" in
http://*|https://*) expected="$to" ;;
*) expected="$base$to" ;;
esac
# Status and redirect destination (full URL) of the old URL
read -r code location <<< "$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$base$from")"
# Status of the destination itself
final=$(curl -s -o /dev/null -w '%{http_code}' "$expected")
if [ "$code" = "301" ] && [ "$location" = "$expected" ] && [ "$final" = "200" ]; then
result=OK
else
result=NG
fi
printf '%s\t%s\t%s\t%s\texpected=%s\tfinal=%s\n' "$result" "$code" "$from" "$location" "$expected" "$final"
done | tee redirect-check.log
# Number of NG rows
grep -c '^NG' redirect-check.log
Each result line contains: OK/NG, the status of the old URL, the old path, the actual destination, the expected destination, and the status of the destination. An NG line falls into one of these cases.
- Status
404: the redirect for that row is not working (check where it sits in.htaccessor how themapkey is written) - Status
302: the redirect is not permanent (checkR=301orreturn 301) - Destination differs from the expected one: another rule matches first, or the table is wrong
finalis not 200: the destination page does not exist, or it redirects again
If you use a caching plugin or a CDN, old 404 responses may still be cached, so purge the cache before verifying.
Finally, open the relevant reason in Search Console's Pages report and click "Validate fix" to speed up recrawling.
Summary
- If every page is a 404, the rewrite rules are the problem. Check, in order: re-saving the permalinks,
.htaccessandAllowOverride,mod_rewrite, and Nginx'stry_files - For 404s on specific URLs, list them from Search Console and the access logs, then build an old-to-new mapping table
- Do not send pages without a destination to the home page in bulk. Sometimes leaving a 404 or returning 410 is the right call
- Handle regular changes with one regular expression and individual redirects by generating rules from the table. Add
.htaccessrules before# BEGIN WordPress - When verifying, compare the full URL returned by
curlwith "site URL + target", and confirm the 301, the destination and the destination's 200 for every row
Related articles
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)