WordPress Custom Post Types & Taxonomies: Complete Developer Guide

Custom post types are how you tell WordPress that your data isn’t a blog post. Products, events, team members, properties, courses — any content with its own structure and its own admin screen needs a custom post type. The same goes for taxonomies: if you need to categorise that content in a way that doesn’t fit default categories and tags, you register a custom taxonomy.

Both get registered on init with a single function call. The WordPress docs list every possible argument, which makes both functions look intimidating — most of those arguments have sensible defaults and you’ll rarely touch them. This guide focuses on the ones you’ll actually configure.

Registering a custom post type

Here’s a real-world example — a plugin that manages events:

add_action( 'init', 'myplugin_register_event_post_type' );

function myplugin_register_event_post_type(): void {
    $labels = [
        'name'               => 'Events',
        'singular_name'      => 'Event',
        'add_new'            => 'Add New',
        'add_new_item'       => 'Add New Event',
        'edit_item'          => 'Edit Event',
        'new_item'           => 'New Event',
        'view_item'          => 'View Event',
        'search_items'       => 'Search Events',
        'not_found'          => 'No events found',
        'not_found_in_trash' => 'No events found in trash',
        'menu_name'          => 'Events',
    ];

    register_post_type( 'event', [
        'labels'       => $labels,
        'public'       => true,
        'has_archive'  => true,
        'rewrite'      => [ 'slug' => 'events' ],
        'supports'     => [ 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ],
        'menu_icon'    => 'dashicons-calendar-alt',
        'menu_position'=> 5,
        'show_in_rest' => true,   // required for Gutenberg support
    ] );
}
PHP

The post type key — 'event' — must be 20 characters or fewer, lowercase, no spaces. It’s stored in the database, so changing it later means migrating data. Pick it carefully the first time.

The arguments that matter most

ArgumentWhat it doesDefault
publicMakes the CPT visible in the admin and on the front endfalse
has_archiveEnables an archive page at /events/false
rewriteControls the URL slug for single postspost type key
supportsWhich editor fields to show (title, editor, thumbnail, etc.)['title','editor']
show_in_restExposes the CPT via REST API and enables Gutenbergfalse
menu_iconDashicon slug or SVG data URI for the admin menugeneric post icon
capability_typeBase for permission checks ('post' reuses post caps)'post'
hierarchicalSet true for page-like parent/child structurefalse

One thing people miss: show_in_rest => true isn’t optional anymore. Without it, the Gutenberg editor won’t load for your post type — you’ll get the classic editor instead. If you’re building anything in 2026, always set this to true.

Custom capabilities

By default, capability_type => 'post' means WordPress reuses the standard post permissions — anyone who can edit posts can edit your CPT. That’s fine for most cases. But if you need separate permissions — so editors can manage events but not delete them, for example — use a custom capability type:

register_post_type( 'event', [
    // ...
    'capability_type' => 'event',
    'map_meta_cap'    => true,   // let WordPress map meta caps for you
] );

// Then grant the capabilities to roles on activation
register_activation_hook( __FILE__, function() {
    $admin = get_role( 'administrator' );
    $admin->add_cap( 'edit_events' );
    $admin->add_cap( 'publish_events' );
    $admin->add_cap( 'delete_events' );
    // ... etc
} );
PHP

Always set map_meta_cap => true when using a custom capability_type. Without it, WordPress can’t resolve capabilities like edit_post for your CPT and will silently deny access in unexpected places.

Registering a custom taxonomy

Taxonomies work the same way — one function on init. The third argument links the taxonomy to one or more post types:

add_action( 'init', 'myplugin_register_event_type_taxonomy' );

function myplugin_register_event_type_taxonomy(): void {
    $labels = [
        'name'              => 'Event Types',
        'singular_name'     => 'Event Type',
        'search_items'      => 'Search Event Types',
        'all_items'         => 'All Event Types',
        'edit_item'         => 'Edit Event Type',
        'update_item'       => 'Update Event Type',
        'add_new_item'      => 'Add New Event Type',
        'new_item_name'     => 'New Event Type Name',
        'menu_name'         => 'Event Types',
    ];

    register_taxonomy( 'event_type', 'event', [
        'labels'            => $labels,
        'hierarchical'      => true,    // true = category-like, false = tag-like
        'public'            => true,
        'rewrite'           => [ 'slug' => 'event-type' ],
        'show_in_rest'      => true,    // required for Gutenberg support
        'show_admin_column' => true,    // shows taxonomy column in post list table
    ] );
}
PHP

You can attach a taxonomy to multiple post types by passing an array as the second argument: [ 'event', 'post' ]. This is useful when you want to share a taxonomy across content types — a “location” taxonomy that applies to both events and venues, for instance.

Hierarchical vs non-hierarchical

hierarchical => true gives you a checkbox UI in the editor, parent/child relationships, and behaviour like categories. hierarchical => false gives you a text input with autocomplete, no parent/child, and behaviour like tags. Pick based on how your users will actually add terms — if they’ll be choosing from a fixed list, hierarchical is usually cleaner. If they’ll be typing freeform labels, non-hierarchical is better.

The rewrite rules problem

This trips up almost everyone the first time. After registering a custom post type, the archive and single URLs return 404 until you flush the rewrite rules. In development you can do this manually: Settings → Permalinks → Save Changes (without changing anything). In a plugin, flush on activation:

register_activation_hook( __FILE__, function() {
    // Register the post type first
    myplugin_register_event_post_type();

    // Then flush — expensive operation, only do this on activation/deactivation
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, function() {
    unregister_post_type( 'event' );
    flush_rewrite_rules();
} );
PHP

Never call flush_rewrite_rules() on every request or inside init. It’s a database write that rebuilds the entire rewrite rule table — doing it repeatedly will noticeably slow down the site. Activation and deactivation are the only appropriate places.

Querying your custom post type

Once registered, you can query your CPT with WP_Query exactly like built-in post types. For the full argument reference, the WP_Query complete guide covers every available parameter including taxonomy queries:

$events = new WP_Query( [
    'post_type'      => 'event',
    'posts_per_page' => 10,
    'post_status'    => 'publish',
    'tax_query'      => [
        [
            'taxonomy' => 'event_type',
            'field'    => 'slug',
            'terms'    => 'conference',
        ],
    ],
    'meta_key'  => 'event_start_date',
    'orderby'   => 'meta_value',
    'order'     => 'ASC',
    'no_found_rows' => true,
] );
PHP

To include your CPT in the main query on archive pages, use pre_get_posts rather than a secondary query. The pre_get_posts guide covers this pattern — it’s more efficient than creating a secondary WP_Query on the template.

Putting it all together in a plugin

A clean structure for a plugin that registers a CPT and taxonomy together — keeping registration, activation, and deactivation in one place:

php
/**
 * Plugin Name: Event Manager
 * Description: Registers the Event custom post type and Event Type taxonomy.
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) exit;

function myplugin_register_types(): void {
    // Post type
    register_post_type( 'event', [
        'labels'        =--> [
            'name'          => 'Events',
            'singular_name' => 'Event',
            'add_new_item'  => 'Add New Event',
            'edit_item'     => 'Edit Event',
            'not_found'     => 'No events found',
        ],
        'public'        => true,
        'has_archive'   => true,
        'rewrite'       => [ 'slug' => 'events' ],
        'supports'      => [ 'title', 'editor', 'thumbnail', 'excerpt' ],
        'menu_icon'     => 'dashicons-calendar-alt',
        'show_in_rest'  => true,
    ] );

    // Taxonomy
    register_taxonomy( 'event_type', 'event', [
        'labels'            => [
            'name'          => 'Event Types',
            'singular_name' => 'Event Type',
            'add_new_item'  => 'Add New Event Type',
        ],
        'hierarchical'      => true,
        'public'            => true,
        'rewrite'           => [ 'slug' => 'event-type' ],
        'show_in_rest'      => true,
        'show_admin_column' => true,
    ] );
}
add_action( 'init', 'myplugin_register_types' );

register_activation_hook( __FILE__, function() {
    myplugin_register_types();
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, function() {
    unregister_post_type( 'event' );
    unregister_taxonomy( 'event_type' );
    flush_rewrite_rules();
} );
PHP

For the WordPress plugin folder structure question — registration code like this belongs in a dedicated includes/class-post-types.php file rather than the main plugin file. The main file should only bootstrap; the actual registration logic lives in a class that gets instantiated on plugins_loaded.

Common mistakes

Forgetting show_in_rest => true and wondering why Gutenberg won’t load. Using a post type key longer than 20 characters (WordPress silently truncates it, which causes hard-to-debug queries). Calling flush_rewrite_rules() on every init. Using a post type key that conflicts with a built-in — post, page, attachment, revision, nav_menu_item, custom_css, customize_changeset, oembed_cache, user_request, wp_block, wp_template, and wp_template_part are all reserved.

And the one that costs the most debugging time: registering the taxonomy after the post type but then trying to query it before it’s associated. Always register both inside the same init callback, or at least make sure your taxonomy registration runs before any queries against it.

Frequently asked questions

What is the difference between a custom post type and a custom taxonomy?

A custom post type is a new content type — events, products, team members. A custom taxonomy is a classification system for that content — event types, product categories, departments. Post types store the actual content; taxonomies organise it. They work together: you register a taxonomy and attach it to one or more post types.

Do I need a plugin like CPT UI to register custom post types?

No. CPT UI is useful for quickly prototyping or for clients who need to manage their own post types, but for anything you’re shipping in a plugin or theme, register via code. UI-registered post types are stored in the database and disappear if the plugin is deactivated. Code-registered ones are always there when your plugin is active.

Why does my custom post type archive return a 404?

Rewrite rules haven’t been flushed. Go to Settings → Permalinks and click Save Changes. If you’re doing this programmatically, call flush_rewrite_rules() inside your activation hook after registering the post type.

Can I add a custom post type to the main blog query?

Yes, with pre_get_posts. Add the CPT to the post_type argument on the main query for the home page or archive. Don’t set 'exclude_from_search' => false and expect it to appear automatically — that only controls search, not the main blog index.

How do I show a custom taxonomy filter in the admin post list?

Set 'show_admin_column' => true when registering the taxonomy — this adds a column to the post list table. For the dropdown filter above the list, hook into restrict_manage_posts and render a