Custom Post Types and Taxonomies: A Content Modelling Guide
A practitioner’s guide to custom post types, WordPress taxonomies and post meta: decision rules, registration code, relationship patterns and migration steps.
Open the admin menu of a five-year-old client site and count the custom post types. If there are eleven, and three of them have fewer than four entries each, nobody modelled that content. Somebody reacted to a brief, twice a year, for five years.
Content modelling is the part of a WordPress build that costs nothing on day one and everything on day four hundred. Get it right and new page types are an afternoon. Get it wrong and every feature request turns into a migration, a rewrite rule fight, or a meta_query that takes 1.8 seconds to resolve a filter sidebar.
This is the version of the custom post types guide we actually use internally: the decision rules, the registration code we copy forward between projects, and the specific mistakes we keep having to undo on inherited sites.
- Use a taxonomy when users will filter or browse by the value, post meta when the value is unique to one item, and a new post type only when the thing has its own title, URL and editorial lifecycle.
- Taxonomy queries join
wptermrelationships, which is indexed on both columns.metavalueis not indexed at all, which is why filtering 20,000 posts by a meta field falls over and the same filter as a term does not. - Register everything in a theme or plugin file under version control, never through a UI-only plugin. Prefix keys (
acmeproject), then userewriteandrest_baseto keep URLs and REST routes clean. - Store post-to-post relationships as one meta row per related ID, not a serialised array, so the reverse lookup can use
=instead ofLIKE. - Post type keys are capped at 20 characters and taxonomy keys at 32. Changing either after launch means a database migration, so decide the names while they are still cheap.
Model the nouns, not the pages
The brief arrives as pages. “We need a team page, a case studies page, a locations page with a map.” Build those as pages and you will be hand-editing a grid of nine people in the block editor eighteen months from now, with the ninth person at a slightly different heading level than the other eight.
Start by listing the nouns the business actually tracks: a person, a project, an office, a course, a legal document. Then ask what each one has. Fields, an author, a publish date, a canonical URL, a workflow where someone drafts it and someone else approves it. Nouns with that shape are post types. Nouns without it usually are not.
The second question matters more and gets asked less: who edits this, and how often? A locations list of four offices that changes once every three years does not need a post type, a taxonomy, an archive template and a REST route. It needs a synced pattern and ten minutes. We have built the full apparatus for content that never changed, and it was wasted work that still has to be maintained.

Custom post types, taxonomies or post meta: the three-way decision
Nearly every modelling argument reduces to picking one of three containers. Here is the test we apply, in order.
Does it need its own archive or filter UI? Then it is a taxonomy
If a visitor will ever want “show me everything in X”, X is a term. Sector, region, skill level, product material, event format. Terms get a slug, an archive URL, a description field, term meta for an icon or colour, and hierarchy if you need it. They also get an indexed query path, which matters at scale.
Is the value unique to the single item? Then it is post meta
Price, SKU, start date, latitude, third-party video ID, PDF page count. Nobody browses “all projects with a launch date of 14 March 2024.” Meta is also correct for anything requiring a numeric or date comparison, with one caveat: sorting or filtering by meta across tens of thousands of rows needs care, because wppostmeta indexes metakey and postid but never metavalue.
Does it have a title, a URL and an editor? Then it is a post type
The honest threshold: if the item deserves a page someone could link to, it is a post type. If it is a label attached to other things, it is a taxonomy. We have seen “Testimonials” built as a taxonomy on products, with the quote text crammed into the term description. It works right up until marketing asks for a testimonial with a photo, a video, and its own landing page.
The inverse mistake is more common and more expensive: a post type called “Project Category” with 60 entries, wired up by hand. That is a taxonomy that got lost on the way to the registration function.
Registering a model you can actually maintain
Register in code. A file in the theme (or better, a small site-specific plugin so the model survives a redesign), loaded on init, committed to Git. UI builders like the post type editors in ACF or Pods are fine for prototyping, and both can export to PHP, but a model that only exists in the production database is a model you cannot diff, review or deploy. If your team is not set up for that yet, our notes on staging, version control and deploys for WordPress teams cover the plumbing.
<?php
// inc/content-model.php — required from functions.php, lives in version control.
addaction( 'init', 'acmeregistercontentmodel' );
function acmeregistercontent_model() {
registerposttype( 'acme_project', array(
'labels' => array(
'name' => __( 'Projects', 'acme' ),
'singular_name' => __( 'Project', 'acme' ),
),
'public' => true,
'showinrest' => true, // needed for the block editor, and for any headless front end later
'restbase' => 'projects', // keeps /wp-json/wp/v2/projects clean despite the acme prefix
'menu_icon' => 'dashicons-portfolio',
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt', 'revisions', 'custom-fields' ),
'has_archive' => 'projects',
'rewrite' => array( 'slug' => 'projects', 'with_front' => false ),
'mapmetacap' => true,
) );
registertaxonomy( 'acmesector', array( 'acme_project' ), array(
'labels' => array(
'name' => __( 'Sectors', 'acme' ),
'singular_name' => __( 'Sector', 'acme' ),
),
'public' => true,
'showinrest' => true,
'rest_base' => 'sectors',
'hierarchical' => true, // checkbox UI and parent/child, like categories
'showadmincolumn' => true, // the single most appreciated line in this file
'rewrite' => array( 'slug' => 'sector', 'with_front' => false ),
) );
registerpostmeta( 'acmeproject', 'acmelaunch_date', array(
'type' => 'string',
'single' => true,
'showinrest' => true, // without this the block editor cannot read or bind it
'sanitizecallback' => 'sanitizetext_field',
'authcallback' => function () { return currentusercan( 'editposts' ); },
) );
}
Four details in there earn their place. restbase means your prefixed internal key never leaks into API paths. showadmincolumn puts the taxonomy in the posts list table, which editors notice within a day of launch. withfront => false stops the CPT slug inheriting your blog permalink prefix. And registerpostmeta with showinrest is what makes the Block Bindings API work, so a template can print a meta value without a shortcode or a custom block:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"acmelaunchdate"}}}}} -->
<p></p>
<!-- /wp:paragraph -->
Bindings landed in WordPress 6.5 and became genuinely usable in the editor UI from 6.7 onward. One thing that trips people up constantly: if your meta key starts with an underscore it is protected and bindings will refuse it.
One rule we enforce without exception: never call flushrewriterules() on init. It rewrites an option on every request. Flush on plugin activation, or just run wp rewrite flush after deploy.
Designing WordPress taxonomies that survive 5,000 terms
The reason WordPress taxonomies beat meta for anything filterable is structural. A taxquery joins wptermrelationships, whose primary key covers objectid plus termtaxonomyid with a second index on termtaxonomyid. A metaquery on a value joins wppostmeta and compares an unindexed longtext column. On a 30,000-post catalogue we migrated last year, moving a single “material” filter from meta to a taxonomy took the archive query from well over a second to under a tenth of that, measured with Query Monitor on the same host and dataset. Same result set. Different index.
That is also the first thing to check when a shop gets slow. WooCommerce attributes are taxonomies for exactly this reason, while a lot of third-party filter plugins quietly fall back to meta. We go deeper into that in why WooCommerce product pages crawl.
Decisions worth making deliberately:
- Hierarchical or flat. Hierarchical gives you checkboxes, parents and nested archive URLs. Flat gives you a tag-style free-text box, which means editors will create “Fintech”, “fintech” and “Fin-tech” by Thursday. Default to hierarchical for anything controlled.
- Sharing a taxonomy across post types. Passing multiple post types to
registertaxonomyis powerful for cross-type archives. Do not sharecategoryorposttagwith a CPT just for convenience: your blog category archives will fill with projects and editors will not understand why. - Term meta instead of a parallel options page.
registertermmetaplusshowinresthandles the hero image, the intro copy and the SEO override for a sector archive. No ACF options page keyed by term slug. - Term order. Terms have no native ordering. If the client needs a specific sequence, add an integer term meta and sort on it. Dragging rows in a plugin UI is a dependency you will regret.
Post-to-post relationships without a graph database
Every real project hits this: a Project links to Services, a Course links to Instructors, an Event links to a Venue. WordPress has no first-class relationship. You pick one of three approaches, and the choice is about which direction you query.
If you only ever query one way (show the services on this project page), an ACF relationship field or a plain array of IDs is fine. If you need the reverse (show every project that used this service), serialised arrays are a trap. The only way to find them is LIKE '%"42"%' against an unindexed column. The fix is one meta row per relationship:
// One row per related post, so the reverse lookup can use '=' instead of LIKE.
deletepostmeta( $projectid, 'acmerelated_service' );
foreach ( $serviceids as $serviceid ) {
addpostmeta( $projectid, 'acmerelatedservice', (int) $serviceid );
}
$projects = new WP_Query( array(
'posttype' => 'acmeproject',
'postsperpage' => 12,
'meta_query' => array(
array(
'key' => 'acmerelatedservice',
'value' => $service_id,
'compare' => '=',
'type' => 'NUMERIC',
),
),
) );
ACF stores relationship fields as a serialised array by default, so if you need bidirectional queries at scale, either enable its bidirectional setting or mirror the IDs into individual rows on save_post. For genuinely heavy graphs (tens of thousands of edges, multiple hops) a custom table with two indexed integer columns is the right answer, and it is less work than it sounds.
URLs, archives and the template hierarchy
Permalink structure is the decision most likely to be made by accident. Three things to settle before launch:
Slug collisions. A CPT with rewrite => array( 'slug' => 'services' ) and a Page called “Services” will fight, and the page usually loses in confusing ways. Check the slug against existing pages first, every time.
Terms in the post URL. Clients ask for /projects/fintech/acme-bank/. Getting there means a %acmesector% rewrite tag, a posttype_link filter and a rule for posts in multiple sectors or none. We build it when SEO genuinely requires it and refuse it otherwise, because every one of those installs has needed a rewrite debugging session later. Flat /projects/acme-bank/ with term archives at /sector/fintech/ gives you the same crawl paths with none of the fragility.
Templates. In a block theme, that is templates/single-acmeproject.html, templates/archive-acmeproject.html and templates/taxonomy-acme_sector.html. Classic themes use the PHP equivalents. Either way, build the empty state at the same time: a sector with zero published projects is a page a user will hit, and the default “Nothing found” is not an answer. We wrote about that in designing for failure.
Migrating an existing model without breaking IDs
Reclassifying posts is cheap if you keep the IDs. Changing post_type on an existing row preserves the ID, the attachments, the comments and the meta, so internal links by ID keep working. Only the URL changes, which means redirects.
# Always on a staging copy first, with a database snapshot.
wp post list --posttype=post --categoryname=case-studies --format=ids \
| xargs wp post update --posttype=acmeproject
Mirror the old category terms into the new taxonomy (terms must already exist).
for id in $(wp post list --posttype=acmeproject --format=ids); do
wp post term list "$id" category --field=slug \
| xargs -r -I{} wp post term add "$id" acme_sector {} --by=slug
done
wp rewrite flush
wp cache flush
Then add the 301s from the old paths, and check your sitemap regenerated. If the terminal is not where you live, WP-CLI for people who avoid the terminal covers enough to run the above safely.
Smells that mean the model is wrong
- A post type with three entries, ever. That was a page, or a pattern.
- Meta fields named
field1throughfield8. Someone needed a repeater and built a row of slots instead. - Editors copying a post to use as a template. Your model does not express a variant that the content clearly has.
- A taxonomy where every term has exactly one post. That is a title, not a classification.
- The same list of options duplicated in a select field on three post types. One shared taxonomy, registered against all three.
- Filters that need a plugin to do what
tax_querydoes natively. Usually a sign the filterable attributes went into meta.
None of these are fatal on their own. Two or more together, and the next feature request is going to hurt. If you are starting fresh and want the templating side already solved against a sane model, CanvasWP ships archive and single layouts you can point at your own post types rather than building grids from scratch.
Frequently Asked Questions
Should I register custom post types in a plugin or in the theme?
A site-specific plugin, in almost every case. Post types registered in a theme vanish when the theme changes, which turns a redesign into a data recovery exercise, since the posts stay in the database but become invisible in the admin. Keep presentation in the theme and the content model in a plugin that is activated for the life of the site.
How many custom post types is too many?
There is no hard limit, but past roughly eight to ten on a non-enterprise site, the admin menu becomes unusable and the model is usually over-fragmented. Before adding one, check whether the new thing differs from an existing type by more than a couple of fields. If it does not, a taxonomy term plus conditional template parts is cleaner than a parallel post type.
Can I change a post type key after launch?
Yes, but it is a migration, not a config change. The key is stored on every row in wp_posts, so you need a wp post update pass or a direct SQL update, plus rewrite flush, redirects, and a sweep of any hardcoded references in templates, queries and third-party plugin settings. Keys are limited to 20 characters, so pick a prefixed name you can live with at the start.
Taxonomy or ACF select field for a filterable attribute?
Taxonomy, if users filter or browse by it. A select field writes to wp_postmeta, where the value column has no index, so filtering gets slower as content grows and you get no archive URL for free. Select fields are the right tool for values that are displayed but never queried across posts.
Do I still need custom post types for a headless or static build?
Yes, and they matter more. The model defines your REST or GraphQL shape, so showinrest and a clean rest_base determine what the front end consumes. Register post meta explicitly too, because unregistered meta does not appear in the REST response at all, which is the most common reason a headless build mysteriously cannot see a field.
Where to start
Before you write a line of registration code, do this: list every noun in the brief on one page, mark each as post type, taxonomy, meta or pattern, and write next to it who edits it and how often. It takes twenty minutes and it will catch at least one wrong call.
If you are inheriting an existing site, run the smell list above against it instead and fix the single worst offender first. On most builds that is a filterable attribute sitting in post meta, and moving it to a taxonomy is a contained change with an immediately measurable payoff.


