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
] );
}
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
| Argument | What it does | Default |
|---|---|---|
public |
Makes the CPT visible in the admin and on the front end | false |
has_archive |
Enables an archive page at /events/ |
false |
rewrite |
Controls the URL slug for single posts | post type key |
supports |
Which editor fields to show (title, editor, thumbnail, etc.) | ['title','editor'] |
show_in_rest |
Exposes the CPT via REST API and enables Gutenberg | false |
menu_icon |
Dashicon slug or SVG data URI for the admin menu | generic post icon |
capability_type |
Base for permission checks ('post' reuses post caps) |
'post' |
hierarchical |
Set true for page-like parent/child structure |
false |
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
} );
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
] );
}
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();
} );
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,
] );
To include your CPT in the main query on archive pages, use [pre_get_posts](/blog/pre-get-posts-wordpress/) 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();
} );
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 with your taxonomy terms, then use parse_query to apply the filter. WordPress doesn’t do this automatically for custom taxonomies. About MeI’m Kamal, a WordPress developer focused on plugins, APIs, and scalable products.Learn More .droip-s579-artevia_dpp8cwcr{background-image:none;background-color:var(--artevia_dpb3xql7);padding:0px 26px 50px 26px;}.droip-s579-artevia_dpr8mpqx{background-image:none;background-color:var(--artevia_dppfo98z);border-radius:24px;padding:56px 64px 40px 64px;}.droip-s579-artevia_dppob8pt{width:100%;max-width:1260px;padding:0px;margin:0px auto 0px auto;}.droip-s579-artevia_dpo4p99c{width:100%;display:flex;align-items:flex-end;justify-content:space-between;column-gap:24px;}.droip-s579-artevia_dp99ar9j{display:flex;justify-content:space-between;width:100%;max-width:none;align-items:flex-end;position:relative;}.droip-s579-artevia_dpdq3en3{overflow:visible;max-width:366.953125px;width:100%;}.droip-s579-dpg74elm{position:absolute;inset:0px auto auto auto;}.droip-s579-artevia_dp27pmn5{font-family:var(--artevia_dp1dq8j9);font-size:80px;font-weight:700;text-transform:uppercase;color:var(--artevia_dppfo98z);fill:var(--artevia_dppfo98z);margin:0px;}.droip-s579-artevia_dp27pmn5.droip-s579-artevia_dplegth3{color:var(--artevia_dpvtsy2o);fill:var(--artevia_dpvtsy2o);font-size:80px;line-height:67px;max-width:390px;word-break:normal;margin:0px;}.droip-s579-artevia_dpdnmask{display:flex;flex-direction:column;row-gap:339px;}.droip-s579-artevia_dpg8nyvr{display:flex;flex-direction:column;row-gap:24px;}.droip-s579-artevia_dpcd82td{color:var(--artevia_dppfji8v);fill:var(--artevia_dppfji8v);font-size:14px;font-weight:400;margin:0px;}.droip-s579-artevia_dpo15h52{display:flex;align-items:center;justify-content:flex-start;column-gap:14px;color:var(--artevia_dpvtsy2o);fill:var(--artevia_dpvtsy2o);font-weight:400;font-style:normal;text-decoration:none;border-radius:12px;border-right-color:var(--artevia_dpvtsy2o);border-left-color:var(--artevia_dpvtsy2o);transition-property:all;transition-duration:300ms;transition-delay:0ms;transition-timing-function:ease-in-out;padding:8px 20px 8px 20px;border-style:none solid none solid;}.droip-s579-artevia_dpo15h52:hover{color:var(--artevia_dpvtsy2o);fill:var(--artevia_dpvtsy2o);}.droip-s579-artevia_dpssqnug{width:100%;height:100%;max-width:30px;max-height:30px;border-radius:4px;}.droip-s579-artevia_dpj5dv0s{font-size:16px;line-height:22px;font-weight:400;letter-spacing:-0.32px;width:95px;margin:0px;}.droip-s579-artevia_dpo15h52.droip-s579-artevia_dpatkneo{border-radius:12px;border-width:1px 1px 0 1px;border-style:solid solid none solid;border-color:var(--artevia_dpvtsy2o) var(--artevia_dpvtsy2o) #000000 var(--artevia_dpvtsy2o);}.droip-s579-artevia_dpo15h52.droip-s579-artevia_dpl7ixlk{border-right-color:var(--artevia_dpvtsy2o);border-bottom-color:var(--artevia_dpvtsy2o);border-left-color:var(--artevia_dpvtsy2o);padding:8px 20px 8px 20px;border-width:0 1px 1px 1px;border-style:none solid solid solid;}.droip-s579-artevia_dpyzxxst{width:100%;display:flex;flex-direction:row;row-gap:24px;justify-content:flex-end;column-gap:24px;}.droip-s579-artevia_dppik9nw{color:var(--artevia_dpk5burm);fill:var(--artevia_dpk5burm);font-size:16px;line-height:20px;font-weight:400;font-family:var(--artevia_dp1dq8j9);width:auto;transition-property:all;transition-duration:300ms;transition-delay:0ms;transition-timing-function:ease-out;font-style:normal;text-decoration:none;margin:0px;}.droip-s579-artevia_dppik9nw:hover{color:var(--artevia_dp7l1apf);fill:var(--artevia_dp7l1apf);}.droip-s579-artevia_dpgvvdlg{width:auto;justify-content:flex-end;margin:0px 0 0px 0px;}.droip-s579-artevia_dpx8hfpw-copy{color:var(--artevia_dpk5burm);fill:var(--artevia_dpk5burm);font-size:16px;line-height:20px;font-weight:400;font-family:var(--artevia_dp1dq8j9);width:auto;transition-property:all;transition-duration:300ms;transition-delay:0ms;transition-timing-function:ease-out;text-wrap:nowrap;margin:0px;}.droip-s579-artevia_dpx8hfpw-copy:hover{color:var(--artevia_dpk5burm);fill:var(--artevia_dpk5burm);}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpp8cwcr{padding:0px 36px 50px 36px;margin:0px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpr8mpqx{padding:56px 16px 40px 16px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dppob8pt{max-width:696px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpo4p99c{flex-direction:row;align-items:flex-end;justify-content:space-between;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dp99ar9j{width:82%;flex-direction:column;align-items:flex-start;row-gap:24px;}}@media only screen and (max-width: 991px) {.droip-s579-dpg74elm{inset:auto -100px auto auto;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dp27pmn5{font-size:32px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dp27pmn5.droip-s579-artevia_dplegth3{font-size:60px;line-height:69px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpcd82td{line-height:16px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpyzxxst{width:100%;row-gap:12px;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dppik9nw{width:100%;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpgvvdlg{width:60%;display:flex;justify-content:flex-end;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpx8hfpw-copy{width:auto;}}@media only screen and (max-width: 991px) {.droip-s579-artevia_dpx8hfpw-copy:hover{color:var(--artevia_dpk5burm);fill:var(--artevia_dpk5burm);}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpp8cwcr{padding:0px 36px 20px 36px;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpo4p99c{flex-direction:row;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dp99ar9j{width:85%;flex-direction:column;align-items:flex-start;row-gap:32px;}}@media only screen and (max-width: 767px) {.droip-s579-dpg74elm{inset:30px -100px auto auto;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dp27pmn5.droip-s579-artevia_dplegth3{line-height:54px;font-size:48px;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpdnmask{row-gap:40px;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpj5dv0s{line-height:18px;font-size:14px;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpyzxxst{width:100%;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dppik9nw{line-height:18px;width:auto;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpgvvdlg{width:auto;}}@media only screen and (max-width: 767px) {.droip-s579-artevia_dpx8hfpw-copy{line-height:18px;width:auto;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpp8cwcr{padding:0px 16px 20px 16px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpr8mpqx{padding:100px 24px 40px 24px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dppob8pt{max-width:440px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpo4p99c{flex-direction:column;align-items:flex-start;column-gap:10px;row-gap:30px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dp99ar9j{width:70%;flex-direction:column;row-gap:40px;align-items:flex-start;}}@media only screen and (max-width: 575px) {.droip-s579-dpg74elm{inset:30px -70px auto auto;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dp27pmn5{line-height:31px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dp27pmn5.droip-s579-artevia_dplegth3{font-size:32px;line-height:32px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpdnmask{row-gap:40px;flex-direction:column;align-items:flex-start;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpyzxxst{width:100%;justify-content:flex-start;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dppik9nw{width:auto;font-size:12px;line-height:14px;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpgvvdlg{width:auto;}}@media only screen and (max-width: 575px) {.droip-s579-artevia_dpx8hfpw-copy{width:auto;font-size:12px;line-height:14px;}}@media only screen and (max-width: 430px) {.droip-s579-dpg74elm{position:absolute;inset:29px -60px auto auto;}} @import"https://use.typekit.net/kmj5qkr.css"; :root { --h: polygon(50% 0, 100% 24%, 100% 76%, 50% 100%, 0 76%, 0 24%) } .h { display: inline-block; background: #25a9ef; padding: 6px; clip-path: var(--h) } .a { width: 200px; padding: 24px 0 40px; display: flex; flex-direction: column; align-items: center; gap: 8px; color: #204ecf; text-align: center; background: radial-gradient(circle at 20% -10%, #00c3ff -80%, #fff 30%), radial-gradient(circle at -20% 20%, #00c3ff -80%, #fff 30%), radial-gradient(circle at 70% 100%, #00c3ff -80%, #fff 30%), radial-gradient(circle at 120% 80%, #00c3ff -80%, #fff 30%); background-blend-mode: multiply; clip-path: var(--h); box-shadow: 0 28px 50px rgba(6, 30, 96, .35) } #r { font-family: proxima-nova, Arial, sans-serif } .b { margin: 0; font-size: 19px; font-weight: 700; line-height: 1 } .c { width: 120px; height: 1px; background: #25a9ef } .d { font-size: 16px; margin-bottom: -6px } .f { display: inline-flex; align-items: center; justify-content: center; padding: 4px 20px; border-radius: 6px; background: #296bff; color: #fff; font-size: 16px; font-weight: 500; text-decoration-thickness: .5px; text-underline-offset: 2px } TOP 3% TALENT Vetted by Hire me Kamal HosenSocial LinkedIn Github X( Twiitter)PrivacyTermsⒸ 2025 Kamal Hosen. All Rights Reserved