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 .htaccess or how the map key is written)
  • Status 302: the redirect is not permanent (check R=301 or return 301)
  • Destination differs from the expected one: another rule matches first, or the table is wrong
  • final is 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, .htaccess and AllowOverride, mod_rewrite, and Nginx's try_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 .htaccess rules before # BEGIN WordPress
  • When verifying, compare the full URL returned by curl with "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)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲