WordPress guide

WordPress Custom Post Types Explained

What custom post types are, when to use one instead of categories or pages, and how to register, template and query them without the usual mistakes.

Updated · 9 min read

Out of the box WordPress gives you posts and pages. Posts are dated, belong to categories and tags, and appear in the blog feed. Pages are hierarchical and timeless. As soon as a site needs to manage something that is neither a blog article nor a static page — products, team members, case studies, events, recipes, job listings — a custom post type (CPT) is usually the right tool.

Under the hood every piece of content lives in the same wp_posts table. A post type is simply the value of the post_type column plus a registration that tells WordPress how that content should behave: whether it has public URLs, an archive, which editor features it supports and who can edit it.

When to use a custom post type

The decision comes down to whether the content has its own shape and lifecycle. Use a CPT when:

  • The content has a distinct set of fields (an event has a date and venue; a team member has a role and photo).
  • It should be listed, filtered or queried separately from the blog.
  • It needs its own URL structure, such as /events/summer-meetup/.
  • Editors benefit from a dedicated admin menu instead of hunting through posts.
  • Different users should have different permissions for it.

Don't use a CPT when a simpler mechanism already fits:

  • Categories or tags are enough when the content is still a blog post, just grouped differently (“Tutorials” vs “News”).
  • Pages are enough for a handful of one-off, hand-built pages such as About or Contact.
  • A custom taxonomy is the answer when you need a new way to classify existing content, not a new kind of content. Taxonomies and CPTs often go together — an Events CPT with a “Venue” taxonomy, for example. See the taxonomy generator for that half.

Registering a post type

Post types are registered with register_post_type() on the init hook. A realistic, production-ready registration looks like this:

<?php
add_action( 'init', 'acme_register_event_post_type' );

function acme_register_event_post_type() {
    $labels = array(
        'name'               => _x( 'Events', 'post type general name', 'acme' ),
        'singular_name'      => _x( 'Event', 'post type singular name', 'acme' ),
        'add_new_item'       => __( 'Add New Event', 'acme' ),
        'edit_item'          => __( 'Edit Event', 'acme' ),
        'view_item'          => __( 'View Event', 'acme' ),
        'all_items'          => __( 'All Events', 'acme' ),
        'search_items'       => __( 'Search Events', 'acme' ),
        'not_found'          => __( 'No events found.', 'acme' ),
        'not_found_in_trash' => __( 'No events found in Trash.', 'acme' ),
        'menu_name'          => _x( 'Events', 'admin menu', 'acme' ),
    );

    register_post_type( 'acme_event', array(
        'labels'          => $labels,
        'public'          => true,
        'has_archive'     => true,
        'show_in_rest'    => true,
        'menu_icon'       => 'dashicons-calendar-alt',
        'menu_position'   => 20,
        'supports'        => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions' ),
        'rewrite'         => array( 'slug' => 'events', 'with_front' => false ),
        'capability_type' => 'post',
    ) );
}

Writing the labels array by hand is tedious and easy to get inconsistent. The custom post type generator builds the full registration, including every label, from a few form fields.

The arguments that matter most

ArgumentWhat it controls
publicThe master switch. It sets the defaults for publicly_queryable, show_ui, show_in_nav_menus and exclude_from_search. Use false for internal data that should never have front-end URLs.
has_archiveEnables a listing page such as /events/. Pass a string to use a different archive slug.
show_in_restExposes the type in the REST API. Required for the block editor — without it, the classic editor is shown.
supportsEditor features: title, editor, thumbnail, excerpt, custom-fields, revisions, page-attributes, comments, author.
rewriteURL structure. slug sets the base; with_front => false stops a /blog/ permalink prefix from being added.
hierarchicalPage-like parent/child relationships. Add page-attributes to supports to get the parent selector.
capability_typeBase for capability names. post reuses post permissions; a custom value like array( 'event', 'events' ) with map_meta_cap => true gives you edit_events, publish_events and so on to assign to roles.

Template hierarchy for custom post types

WordPress looks for templates in a predictable order. For a post type registered as acme_event:

  • Single entry (classic theme): single-acme_event-{slug}.php → single-acme_event.php → single.php → singular.php → index.php
  • Archive (classic theme): archive-acme_event.php → archive.php → index.php
  • Block themes: the same names with an .html extension in the templates/ folder — templates/single-acme_event.html and templates/archive-acme_event.html. You can also create them in the Site Editor.

Note that the template name uses the post type key, not the rewrite slug. That is a frequent source of “why is my template ignored?” questions.

Querying custom post types

Custom post types are not mixed into the main blog loop. To display them elsewhere — on the homepage, in a sidebar or in a shortcode — use WP_Query:

<?php
$events = new WP_Query( array(
    'post_type'      => 'acme_event',
    'posts_per_page' => 6,
    'meta_key'       => 'event_date',
    'orderby'        => 'meta_value',
    'order'          => 'ASC',
    'no_found_rows'  => true, // skip pagination count when you don't paginate
) );

if ( $events->have_posts() ) :
    while ( $events->have_posts() ) : $events->the_post(); ?>
        <article>
            <h3><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h3>
            <?php the_excerpt(); ?>
        </article>
    <?php endwhile;
    wp_reset_postdata();
endif;

To change how the archive itself is queried (posts per page, ordering), don't run a second query inside the template. Modify the main query with pre_get_posts instead:

<?php
add_action( 'pre_get_posts', function ( $query ) {
    if ( is_admin() || ! $query->is_main_query() ) {
        return;
    }
    if ( $query->is_post_type_archive( 'acme_event' ) ) {
        $query->set( 'posts_per_page', 12 );
        $query->set( 'orderby', 'title' );
        $query->set( 'order', 'ASC' );
    }
} );

If you want to expose events through a shortcode for editors, the shortcode generator creates the boilerplate around a query like the one above.

Plugin or theme?

Register post types in a plugin (a small site-specific plugin or a must-use plugin in wp-content/mu-plugins/), not in the theme's functions.php. Content is data; if the post type is registered by the theme, switching themes makes every event disappear from the admin and every URL return a 404. The content is still in the database, but nothing tells WordPress how to handle it.

Theme placement is acceptable only for throwaway prototypes or when the post type genuinely exists only to serve that theme's presentation. Our functions.php snippets page covers what does belong in a theme.

Flushing rewrite rules

WordPress caches its URL rewrite rules. After registering a new post type or changing its rewrite or has_archive arguments, the new URLs return 404 until the rules are rebuilt. The quick fix during development is to visit Settings → Permalinks and click Save.

In a plugin, flush once on activation — never on every request, because flushing is expensive:

<?php
register_activation_hook( __FILE__, function () {
    acme_register_event_post_type(); // register first so the rules exist
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

Common mistakes

Exceeding the 20-character key limit

The post type key is stored in a varchar(20) column. Keys longer than 20 characters trigger a _doing_it_wrong notice and registration fails. Use lowercase letters, numbers, underscores and dashes only.

Using reserved names

Never register post, page, attachment, revision, nav_menu_item, custom_css, customize_changeset, oembed_cache, user_request, wp_block, wp_template, wp_template_part, wp_global_styles, wp_navigation, action, author, order or theme. Also avoid the wp_ prefix entirely. Generic keys such as event or product can collide with plugins, so prefix them (acme_event) and use the rewrite slug for the pretty URL.

Forgetting show_in_rest

Since show_in_rest defaults to false, a post type registered without it opens in the classic editor and is invisible to the REST API, the Query Loop block and headless front ends. If your editors expect blocks, set it to true.

Changing the key after launch

Renaming the key orphans existing content because rows in wp_posts still reference the old value. If you must rename, update the post_type column with a migration script (or WP-CLI) and add redirects for the old URLs — the WordPress redirect guide explains how.

Rebuilding fields that already exist

Many CPTs need structured fields such as dates, prices or locations. Rather than writing meta boxes by hand, pair the post type with an ACF field group. The ACF field group guide walks through that, and the ACF field generator produces the PHP.

Checklist

  1. Pick a prefixed key of 20 characters or fewer.
  2. Register it on init in a plugin or mu-plugin.
  3. Set show_in_rest, supports, has_archive and a clean rewrite slug.
  4. Add a matching taxonomy if the content needs its own classification.
  5. Flush rewrite rules once, then create single- and archive- templates as needed.
  6. Adjust the archive with pre_get_posts, not secondary queries.