Custom Post Types and Taxonomies
Every WordPress site that grows past 'blog plus a few pages' eventually faces a modeling question: the client has courses, or properties, or team members, or recipes — and someone has to decide how that content lives in the database. Get it right and everything downstream is easy: clean archives, fast filtering, sensible admin screens, editors who can't break things. Get it wrong and you'll spend years paying interest on the decision, usually in the form of slow meta queries and a category dropdown with four hundred entries.
The frustrating part is that WordPress gives you exactly the right tools. There are three of them, they map cleanly onto three different kinds of information, and the whole discipline of WordPress content modeling reduces to knowing which is which.
The three tools, and what each one is for
Strip away the API details and WordPress offers three places to put structured content:
- Post types answer 'what kind of thing is this?' A post type is a noun: post, page, product, course, property. Each item gets its own row in
wp_posts, its own edit screen, its own URL, and (optionally) its own archive. - Taxonomies answer 'how do these things group together?' A taxonomy is a shared vocabulary for classifying items: category, tag, genre, region, difficulty level. Terms exist independently of any one item and many items share them.
- Post meta answers 'what are the properties of this one thing?' Meta is a key–value pair attached to a single item: price, ISBN, event date, a checkbox, a related file.
Three questions, three tools. Nearly every modeling mistake in the wild is an answer to one question stored in the tool built for another.
The decision rules
Here they are as a table, because this is the part worth pinning to the wall:
| If the data is… | Use | Test |
|---|---|---|
| A thing with its own page, title, and content | Custom post type | Would a visitor ever land on a URL for just this item? |
| A label shared across many things, used for browsing or filtering | Taxonomy | Will users ever want a page listing everything with this label? |
| A fact about exactly one thing, displayed on that thing's page | Post meta | Is this value meaningless apart from its parent item? |
Two corollaries do most of the practical work:
If you will ever filter by it, it wants to be a taxonomy
This is the rule people learn the expensive way. Suppose you store a property's city as post meta. It works fine — until the client asks for 'all properties in Antwerp', and now you're running a meta query. Meta queries join against wp_postmeta, where values live in an unindexed-for-this-purpose longtext column; at a few thousand posts they get sluggish, and combining two or three meta conditions multiplies the joins and the pain. Taxonomy queries, by contrast, run through purpose-built, properly indexed term relationship tables and stay fast at scales meta queries can only dream about.
Rule of thumb: meta is for display, taxonomies are for selection. If a value will ever appear in a filter sidebar, a dropdown, or an 'all items with X' archive, model it as a taxonomy even when it feels like a property. 'Bedrooms: 3' feels like a fact about one property — but the moment users filter by bedroom count, a bedrooms taxonomy with terms 1–6 will outperform a meta query every day of the week.
If it's really just a category, don't mint a post type
The inverse mistake: creating a 'Tutorials' custom post type when tutorials are ordinary posts wearing a different hat. Ask: is the structure different, or only the label? Different structure — different fields, different templates, different editors, different lifecycle — justifies a post type. A different label justifies a category. Splitting blog-shaped content across multiple post types buys you real costs (separate archives, feeds that miss content, plugins that only look at post) in exchange for nothing a taxonomy wouldn't have provided.
Registering a post type properly
Here is a real-world registration for a course catalog, with the arguments that matter:
add_action( 'init', function () {
register_post_type( 'course', array(
'labels' => array( 'name' => 'Courses', 'singular_name' => 'Course' ),
'public' => true,
'has_archive' => true,
'show_in_rest' => true,
'menu_icon' => 'dashicons-welcome-learn-more',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),
'rewrite' => array( 'slug' => 'courses' ),
) );
} );
The arguments that deserve actual thought:
publicis a master switch that sets sensible defaults for a whole family of visibility flags (show_ui,publicly_queryable,exclude_from_search, and friends). The useful non-obvious combination:'public' => false, 'show_ui' => truegives you content editors manage in the admin that has no front-end URLs at all — right for testimonials, slides, or snippets that only ever render inside other pages. If a thing should never have its own URL, don't give it one.show_in_restis quietly load-bearing: without it, the post type gets no REST endpoint and no block editor — you'll be staring at the classic editor wondering why. Set it to true unless you have a specific reason not to.has_archivegives you a free listing page at the rewrite slug (/courses/). Decide deliberately: if you're building a custom landing page instead, set it to false so the auto-archive doesn't compete with your page for the URL.supportsis a whitelist — anything you omit disappears from the edit screen. Trimming it is good UX (no comment box on a property listing), but don't striprevisionsfrom anything humans write; you will want the undo history eventually. Note that omittingeditoris legitimate for meta-driven types where all content lives in fields.hierarchical: true makes items nest like pages (parents and children); false makes them flat like posts. Most CPTs should be flat — page-like nesting is for genuine containment relationships, not for tidiness.
Three registration gotchas that cost people afternoons: post type keys are limited to 20 characters, lowercase, no spaces (taxonomy keys get 32); a list of names is reserved by core — post, page, attachment, revision, action, order, theme among them — and colliding with one produces gloriously confusing breakage; and namespacing your keys (acme_course rather than course) is cheap insurance against colliding with a plugin that had the same idea.
Registering taxonomies that editors can actually use
The taxonomy counterpart for our courses:
register_taxonomy( 'course_topic', 'course', array(
'labels' => array( 'name' => 'Topics', 'singular_name' => 'Topic' ),
'hierarchical' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array( 'slug' => 'course-topics' ),
) );
Decisions worth making consciously:
- Hierarchical or flat? Hierarchical taxonomies behave like categories: checkbox UI, parent–child nesting, a curated vocabulary. Flat taxonomies behave like tags: free-text entry, no nesting, an open vocabulary. The choice shapes editor behavior more than anything else you'll set — checkbox vocabularies stay tidy because editors pick from a list; free-tagging vocabularies grow 'Marketing', 'marketing', and 'Marketting' within a month. If the term list should be controlled, hierarchical is the right call even if you never nest anything.
show_admin_columnputs the taxonomy in the post list table. One line, and editors can see and sort by classification at a glance. There is no good reason to skip it.show_in_restagain — without it, the taxonomy panel is missing from the block editor and editors will file that as 'the site is broken'.- Order matters: register the taxonomy before (or in the same
initcallback, ahead of) the post type that uses it, and also pass it in the post type'staxonomiesargument or viaregister_taxonomy_for_object_type(). Registering it after can leave rewrite rules and associations subtly incomplete.
Rewrite rules: the classic tripwire
WordPress translates pretty URLs into queries using rewrite rules, which are cached. When you register a post type or taxonomy with new slugs, the cache doesn't know about them — and you get the famous symptom: your new content 404s even though it obviously exists.
The fix is flushing the rewrite cache, and the etiquette matters:
- In a plugin: flush on activation and deactivation, via
register_activation_hook()— register the types first, then callflush_rewrite_rules(). - During development: visiting Settings → Permalinks and clicking Save flushes the cache. This is the 'have you tried turning it off and on again' of WordPress routing, and it genuinely fixes most CPT 404s.
- Never call
flush_rewrite_rules()oninitor any hook that runs every request. Flushing rebuilds and rewrites the rules on every page load — an expensive operation performed thousands of times a day to solve a problem that occurs once. It's a top-ten WordPress performance sin and it hides in a surprising number of tutorial snippets.
While you're thinking about URLs, check for collisions: a post type archive at /courses/ will fight with an existing page named 'Courses', and the loser 404s. Pick slugs that nothing else owns, and treat changing a slug on a live site as what it is — a URL migration requiring redirects, not a config tweak.
Worked example: a recipe site, modeled twice
To make the rules concrete, consider a recipe site, modeled badly and then well.
The bad model (assembled one client request at a time): recipes are ordinary posts in a 'Recipes' category; cuisine is stored in a meta field; cooking time, servings, and difficulty are meta; 'vegetarian' is a tag on some recipes and a meta flag on others, depending on which developer touched it last. Filtering by cuisine is a slow meta query. Filtering by cuisine and difficulty joins wp_postmeta twice and crawls. The vegetarian filter misses half the recipes. Nobody trusts the archive pages.
The sound model: a recipe post type (recipes are structurally distinct things with their own template and archive). Three taxonomies: cuisine (hierarchical — curated list), diet (hierarchical — vegetarian, vegan, gluten-free), and difficulty (hierarchical, exactly three terms). Meta for the true one-off properties: minutes to cook, servings, calories — displayed on the recipe page, never filtered on. Every filter the client will ever ask for is now an indexed taxonomy query; every displayed fact is a simple meta read. The 'is it filterable?' rule drew the entire boundary.
Note what difficulty teaches: a taxonomy with three terms feels like overkill next to a meta field — until you remember that the whole point of the field is the 'Easy recipes' archive page. Filterable → taxonomy, even when tiny.
When not to build any of this
A short honesty section. Reach for a plain page when there will only ever be a handful of items and no archive — five team members do not need a post type, they need one nicely designed page. Reach for an existing plugin's model rather than a parallel homemade one — if you're selling things, model them as the commerce plugin's products; a custom item_for_sale type next to it is a future data migration with extra steps. And put registration code in a small custom plugin (or mu-plugin), not the theme: content models should survive a redesign, and a CPT registered in a theme vanishes from the admin the moment the theme changes — the data stays in the database, invisible, while everyone panics.
The short version
Post types are nouns, taxonomies are shared labels, meta is per-item facts. Anything users filter by belongs in a taxonomy, because taxonomy queries are indexed and meta queries are not. Register with show_in_rest on, namespace your keys, respect the 20-character limit and the reserved names, flush rewrite rules on activation and never on every request, and keep the registration code in a plugin so the model outlives the theme. Content modeling is the rare WordPress decision that is nearly free to get right at the start and genuinely expensive to fix later — spend the hour.
Photo: https://kaboompics.com/ on Pexels