How to Fix Custom Post Type 404 Errors in WordPress

You register a custom post type, add a few entries, click View, and get your theme’s “Page not found” page. The posts exist in the admin, but WordPress won’t show them on the front end. Sometimes the single posts work but the archive page at /books/ doesn’t, or page 2 of the archive is a 404.

Nearly every case comes down to WordPress’s rewrite rules, the stored list that maps pretty URLs to content. This guide starts with the 30-second fix, then covers the causes that fix doesn’t solve.

The 30-Second Fix: Refresh Permalinks

WordPress doesn’t rebuild its rewrite rules on every page load. It builds them once, stores them in the database, and reuses them. A new post type, or a changed slug, has no rules until something rebuilds them.

Go to Settings → Permalinks and click Save Changes without changing anything. That rebuilds the rules, including your post type’s. Reload the 404 page.

The same custom post type URL before and after saving Permalinks
WordPress custom post type URL showing good after saving permalinks and loading after
The same custom post type URL before and after saving Permalinks

If you have WP-CLI, wp rewrite flush does the same thing. If the page works now, you’re done. If you’re the developer, read the next section so it doesn’t happen to your users.

For Developers: Flush Rules on Activation, Not on Every Load

If your plugin or theme registers the post type, flush the rewrite rules once when it’s activated, after registering the post type in that same request. Never call flush_rewrite_rules() on init or on every page load. It’s an expensive operation that rewrites a database option each time.

function myplugin_register_book() {
    register_post_type( 'book', array(
        'labels'       => array(
            'name'          => 'Books',
            'singular_name' => 'Book',
        ),
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => array( 'slug' => 'books' ),
        'show_in_rest' => true,
    ) );
}
add_action( 'init', 'myplugin_register_book' );

function myplugin_activate() {
    myplugin_register_book();
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'myplugin_activate' );

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

The activation function calls the registration function directly, because init has already run by the time a plugin is activated. The deactivation hook removes the rules again when the plugin is switched off. If you change the rewrite slug later, the rules need flushing again, for example by saving Permalinks once after deploying.

In a theme, there’s no activation hook. Flush on the after_switch_theme action instead, or simply ask users to save Permalinks once.

If Refreshing Permalinks Didn’t Fix It

The archive page is a 404, but single posts work

Post type archives are off by default. Without 'has_archive' => true (or a string such as 'has_archive' => 'books'), there’s no /books/ page at all. Add it and refresh permalinks.

A page has the same slug as the post type

If you have a regular page called “Books” at /books/ and your post type also uses the books slug, the two compete for the same URLs, and one of them loses. Either rename the page’s slug, or change the post type’s rewrite slug, then refresh permalinks. The same applies to a custom taxonomy, category, or tag using a slug that clashes with the post type.

The post type isn’t public, or has no rewrite rules

Check the registration arguments:

  • 'public' => false (or 'publicly_queryable' => false) means the post type isn’t meant to be viewed on the front end at all.
  • 'rewrite' => false means there are no pretty URLs. Entries are only reachable at ?book=slug.

Post type plugins such as CPT UI have the same settings, labelled “Public”, “Publicly Queryable”, “Has Archive”, and “Rewrite”.

The post type is registered too late, or only in the admin

register_post_type() must run on the init hook on every request, front end included. If it’s inside an is_admin() check, or hooked to something that only runs in the admin, the admin shows your posts but the front end has never heard of the post type and returns 404.

Page 2 of the archive is a 404

This happens when an archive or page template runs its own WP_Query with a different posts_per_page than the main query. WordPress decides whether /page/2/ exists from the main query. If the main query has fewer pages, page 2 is a 404 before your template even runs. Change the main query with pre_get_posts instead of running a second query:

function myplugin_books_per_page( $query ) {
    if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive( 'book' ) ) {
        $query->set( 'posts_per_page', 12 );
    }
}
add_action( 'pre_get_posts', 'myplugin_books_per_page' );

Our guide to WordPress hooks explains pre_get_posts in more detail.

See Which Rule Matched the URL

If you still can’t see why a URL fails, check what WordPress matched it to:

  • The free Query Monitor plugin has a Request panel showing the matched rewrite rule and the resulting query variables for the current page. If the matched rule isn’t yours, another post type, taxonomy, or page is taking the URL first.
  • With WP-CLI, wp rewrite list --match=/books/my-book/ lists the rules that match a given path, in the order WordPress tries them.

Other Things to Check

  • Permalinks set to “Plain” don’t use rewrite rules at all, so if posts work at ?book=slug but not at pretty URLs, .htaccess or your server’s rewrite setup is the problem. See our .htaccess guide.
  • Purge your page cache and CDN after fixing. A cached 404 can outlive the fix.
  • Multilingual and membership plugins add their own rewrite rules. If the 404 only happens with one of them active, check its URL settings.

Summary

Custom post type 404s are almost always missing rewrite rules: save Settings → Permalinks, and in your own code flush rules on activation only. If that doesn’t help, check has_archive for archive 404s, slug clashes with pages and taxonomies, the public and rewrite arguments, whether the post type is registered on every request, and pre_get_posts for pagination 404s.