Why Every Permalink 404s After a Database Import: The rewrite_rules Row, flush_rewrite_rules() Timing, and What WP_Rewrite Actually Regenerates

You import the database. The homepage loads. wp-admin loads. Query-string URLs like /?p=123 resolve. Every pretty permalink returns 404. The instinct is to blame the import, the host, or the theme. The actual failure is narrower: the rewrite_rules row in wp_options is a cached artifact of the environment that generated it, and the environment that generated it is not the environment you just imported into.

Symptom

The site is up. The database is intact. Posts exist, terms exist, menus render. But /2024/06/some-post/ returns 404 while /?p=123 returns the post. That split is diagnostic. Query-string URLs bypass the rewrite layer entirely; pretty permalinks depend on it. When one works and the other does not, the routing layer is the suspect, not the content layer.

If you have seen this before, you may have reached for flush_rewrite_rules() and watched it either fix the site or produce a new, subtler breakage. Both outcomes are explainable, and the difference is timing.

Trace: what the rewrite_rules row actually is

WP_Rewrite is the core class that manages the rewrite rules enabling pretty permalinks, and it generates those rules from values in the database. The generated result is stored as a serialized array in the rewrite_rules option. The $rules property holds the rewrite rules and is set when rewrite_rules() is called.

That row is not a configuration file. It is a cache. It was generated at a specific moment, under a specific set of registered rule sources, for a specific site URL and permalink structure. It contains the regex-to-query-var mappings that WP::parse_request() matches against. If the row describes a different site than the one now serving requests, the match fails and WordPress returns 404. The 404 is correct output for the rules that are loaded.

You can inspect the row directly:

SELECT option_value
FROM wp_options
WHERE option_name = 'rewrite_rules';

And the permalink structure it was generated against:

SELECT option_value
FROM wp_options
WHERE option_name = 'permalink_structure';

If permalink_structure is empty, pretty permalinks are not enabled and the rewrite layer is not the problem. If it is populated but rewrite_rules is empty, missing, or contains rules referencing a different host or path, you are looking at a stale cache.

The rewrite_rules option is autoloaded, so checking it does not incur an extra database call. That makes it cheap to inspect from a diagnostic plugin or a one-off script.

Why an import breaks it

The retrieved sources describe how rewrite rules are generated and flushed. They do not document what any particular import tool does with the rewrite_rules row. So the honest framing is this: a database import moves the row, and the row was generated somewhere else. Whether the import tool preserves it, drops it, or overwrites it depends on the tool. What is consistent is that the row travels with the data while the environment that produced it does not.

Three environment differences matter most:

  • Active plugin set. Custom post types, taxonomies, and custom rewrite rules registered by plugins contribute rules. If the source site had a plugin active that the destination does not, the cached row contains rules for post types that no longer exist. If the destination has plugins the source did not, the cached row is missing their rules.
  • Permalink structure. The permalink_structure option drives the shape of the generated rules. A different structure produces a different rule set.
  • Site URL and path. Rules are generated relative to the install root. A site moved from a subdirectory to a domain root, or vice versa, has a rule set that no longer matches incoming request paths.

None of these require the import tool to do anything wrong. The row is simply a snapshot of a different environment.

What WP_Rewrite actually regenerates

When you flush, WP_Rewrite does not read the existing rewrite_rules row and patch it. It discards the cached value and rebuilds from scratch. The inputs are:

  • The permalink_structure option.
  • Registered post types and their rewrite arguments.
  • Registered taxonomies and their rewrite arguments.
  • Registered permastructs added via add_permastruct().
  • Rules added via add_rewrite_rule().
  • Endpoints added via add_rewrite_endpoint().
  • Filters on the generation process, including rewrite_rules_array, post_rewrite_rules, page_rewrite_rules, date_rewrite_rules, search_rewrite_rules, comments_rewrite_rules, author_rewrite_rules, root_rewrite_rules, and {$permastruct}_rewrite_rules.

The generate_rewrite_rules action fires after all rules have been created, and receives a reference to the entire $wp_rewrite object. That is the last point at which a plugin can influence the generated set before it is saved.

This is why flushing is not a repair operation. It is a regeneration operation. It reads whatever is registered at the moment it runs. If a custom post type has not yet registered, its rules will not be in the output. The resulting row will look complete — it will be a valid serialized array with plausible rules — but it will be missing entries. The site will 404 on the missing post type’s permalinks while everything else works.

The timing trap

Rewrite rules are usually registered inside the init hook. The init hook fires after WordPress has finished loading but before any headers are sent; most of WP is loaded and the user is authenticated at that stage. Plugins that register custom post types typically hook their registration to init as well.

That creates an ordering problem. If you call flush_rewrite_rules() before init has run — on a plugin activation hook, for example — the custom post types registered on init have not yet registered. The flush generates rules without them. The documentation for flush_rewrite_rules() states the problem directly: it not only removes the old rewrite rules but also creates new ones based on the post types and rewrite rule filters registered at that point in time, and calling it at an awkward moment where not everything is in place will generate wrong or incomplete rewrite rules.

Core itself acknowledges this. WP_Rewrite::refresh_rewrite_rules() postpones saving fresh rules to the database if the wp_loaded action has not yet occurred, because the rules may be partial and all rules need a chance to register. The source comment is explicit: it is not safe to save the results right now, as the rules may be partial, and all rules need the chance to register.

That is the reference pattern. Any manual recovery procedure should respect the same ordering constraint rather than trying to force a flush at an arbitrary point.

Hard flush vs. soft flush

flush_rewrite_rules() removes rewrite rules and then recreates them. Its $hard parameter controls whether .htaccess is updated (hard flush) or only the rewrite_rules option is updated (soft flush). The default is true — a hard flush.

The distinction matters on hosts where .htaccess is not writable. A hard flush attempts to write server-level rules. If that write fails silently, the option may be correct while the server-level rules remain stale. The site continues to 404 because the request never reaches WordPress’s rewrite layer in the first place. Checking whether .htaccess was actually written is a high-value diagnostic step after any hard flush.

A soft flush — flush_rewrite_rules(false) — updates only the option. It is appropriate when you know the server-level rules are already correct and you only need to regenerate the WordPress-side mapping. It is not a substitute for a hard flush when the server rules are stale.

A safe recovery sequence

The goal is to regenerate the rewrite_rules row from a context where every rule source has registered. That means after init, and ideally after wp_loaded.

Step one: verify the permalink structure is intact.

SELECT option_value
FROM wp_options
WHERE option_name = 'permalink_structure';

If it is empty, set it before doing anything else. Flushing against an empty structure produces a rule set that does not include pretty permalinks.

Step two: verify the active plugin set matches what the site expects. A missing plugin means missing rules. A newly added plugin means rules that were never generated. Either way, the flush needs to happen after the plugin set is settled.

Step three: trigger regeneration from a context where all rule sources are registered. The simplest reliable path is the admin UI: go to Settings → Permalinks and click Save Changes without making changes. The documentation for add_rewrite_rule() describes this as the standard way to flush and regenerate the rewrite rules database after modifying rules.

If you need to do it programmatically, hook late enough that init has completed. A one-time flag prevents repeated flushes:

add_action( 'wp_loaded', function() {
    if ( get_option( 'my_rewrite_flush_needed' ) ) {
        flush_rewrite_rules( false );
        delete_option( 'my_rewrite_flush_needed' );
    }
} );

Set my_rewrite_flush_needed to 1 before the request that should perform the flush. The wp_loaded hook fires after init, so all rule sources registered on init are present. The false argument performs a soft flush; use true only if you also need to rewrite .htaccess and you have confirmed it is writable.

An alternative that avoids the timing problem entirely is to delete the rewrite_rules option and let WordPress regenerate it at the right time. The documentation for flush_rewrite_rules() presents this as a way to force rewrite rules to be recreated at the right time, and notes that it avoids the problem of generating incomplete rules from an awkward hook. The trade-off is that regeneration happens on the next request that needs the rules, which may be a front-end request rather than an admin one. For a self-maintained install, that is usually acceptable.

DELETE FROM wp_options WHERE option_name = 'rewrite_rules';

Or, from WP-CLI:

wp option delete rewrite_rules

WordPress will regenerate the option on the next request. Because the option is autoloaded, the regeneration cost is paid once and the result is cached.

Verification

After flushing, confirm the row actually changed. Capture the value before and after:

SELECT MD5(option_value) AS rules_hash
FROM wp_options
WHERE option_name = 'rewrite_rules';

If the hash is identical before and after, the flush did not regenerate anything. That usually means the flush ran before the rule sources registered, or a filter on rewrite_rules_array is returning an unchanged or empty array.

Check the rule count:

SELECT LENGTH(option_value) AS rules_length
FROM wp_options
WHERE option_name = 'rewrite_rules';

A length of zero or a very small number indicates an empty or near-empty rule set. A populated site with custom post types should have a substantially larger value.

If you performed a hard flush, confirm .htaccess was written. The file should contain the standard WordPress block between # BEGIN WordPress and # END WordPress. If it does not, the write failed and the server-level rules are stale regardless of what the option contains.

Finally, test a permalink that exercises a custom post type or taxonomy, not just a standard post. A flush that ran too early will produce a rule set that handles standard posts correctly while missing custom rules. Testing only a standard post will give a false positive.

What not to do

Do not call flush_rewrite_rules() on every init or every page load as a workaround. The documentation for flush_rewrite_rules() states that it is an expensive operation and should only be used when necessary. Running it on every request pays that cost continuously and masks the registration-order problem that caused the failure. The symptom disappears; the underlying condition does not.

Do not call it on a plugin activation hook before the plugin’s own post types are registered. The documentation’s user-contributed notes describe this exact failure: the activation hook runs before the main plugin code that registers custom post types, so a flush at that point generates rules without them. The recommended pattern is to register the post types first, then flush, or to defer the flush to a later hook.

Do not assume a hard flush succeeded because it did not throw an error. On hosts where .htaccess is not writable, the write can fail without a visible error. Verify the file contents.

FAQ

Does a database import always leave the rewrite_rules row stale?

No. The retrieved sources do not document import tool behavior, so no universal claim is possible. What is consistent is that the row is generated from the environment that produced it. If the destination environment differs in active plugins, permalink structure, or site URL, the row will not match. If the destination is identical, it may still be valid.

Can I just delete the rewrite_rules option instead of flushing?

Yes, and it is often the better move during an import or activation sequence. Deleting the option defers regeneration to a point where the full rule set is available. The documentation presents this as an alternative to calling flush_rewrite_rules() at an awkward moment. It is not universally safer — it depends on when the next request that needs the rules occurs — but it avoids the incomplete-generation problem.

Why does the homepage still work when every permalink 404s?

The homepage is typically served by the root rewrite rule or by index.php directly. It does not depend on the post permalink rules. Query-string URLs like /?p=123 bypass the rewrite layer entirely. The 404s are specific to the rules that are missing or mismatched.

What if the rewrite_rules row looks correct but permalinks still 404?

Check .htaccess. If the server-level rules are stale or missing, the request never reaches WordPress’s rewrite layer. A hard flush should rewrite .htaccess, but if the file is not writable, the write fails silently. Verify the file contains the WordPress block.

Is flush_rewrite_rules() safe to call on every init?

It is technically possible but documented as an expensive operation that should only be used when necessary. Calling it on every request masks the registration-order problem and pays the regeneration cost continuously. The core’s own refresh_rewrite_rules() postpones saving until wp_loaded precisely because early saves can be partial.

Related reading

If you are debugging a fresh install where the symptom is “nothing found” rather than 404s on existing content, the failure mode is different and the diagnostic path is different. See What to Fix First When a New WordPress Site Says Nothing Found for that case.

Sources