Custom Post Types: Structuring Content WordPress Was Not Built For
A WordPress custom post type earns its place when the thing has its own fields, its own archive and its own lifecycle. Most of the time you wanted a taxonomy. Sometimes you wanted neither.
On This Page

Somebody says the word “events” in a planning call, and within the hour there is a new post type in the repository. Six months later it holds four entries. Its archive falls through to a template nobody designed. Its permalink quietly collides with a page of the same name, and it was registered in the theme, so it will vanish from the admin the day the theme changes. The content will still be in the database. Nobody will be able to see it.
That is a modelling mistake wearing a technical costume. Registering a post type takes fifteen lines and about four minutes. Unregistering one, after editors have filled it and search engines have indexed it, takes a data migration, a redirect map and an afternoon where everyone is slightly tense.
So the useful question is not how to register WordPress custom post types. It is when. The platform already ships posts, pages, hierarchy and taxonomies, and for a fair share of projects those primitives hold the content better than anything a team invents on a Tuesday. What follows is the three-way choice we work through before writing any registration code, and the specific arguments that are genuinely hard to reverse once content exists behind them.
The three-way choice nobody frames properly
Nearly every WordPress modelling question resolves into one of three shapes, and naming the shape out loud settles most arguments before they start.
- A record. It has attributes of its own, it belongs on a listing of its own kind, and somebody maintains it on its own schedule. That is a post type.
- A label. It exists to group records that already exist, and it would never be worth reading on its own. That is a taxonomy term.
- A page. It is one document that somebody described as a system because describing it as a page felt too small. That is a page, and it should stay one.
Teams default to the first shape because it feels like architecture, and because plugin tutorials are written by people demonstrating registration rather than restraint. The default costs real money later. Here is the same decision laid out against the things that bite in year two, which is when a website build starts being judged on how easy it is to change.
| Concern | Custom post type | Taxonomy | Ordinary page |
|---|---|---|---|
| What it holds | A record with fields | A label with a name | A document |
| Own editing screen | Yes | A term form, not an editor | Yes |
| Own public URL | Per entry, plus an archive | One archive per term | One URL |
| Groups other content | No | Yes, across post types | No |
| Appears in the blog feed | Only if you ask for it | Not applicable | No |
| Cost to add later | Low | Low | Trivial |
| Cost to remove later | High: data plus URLs | Moderate: URLs | A redirect |
The single test we apply before anyone opens an editor: if you cannot describe the archive page, you are not ready to register the post type. Not sketch it, not build it. Describe it. What is listed, in what order, filtered how, and who arrives there. A team that cannot answer usually discovers within a minute that they wanted a category on the blog.
When a WordPress custom post type earns its place
A post type is justified when the content genuinely differs from a post in structure, not merely in subject. Subject is what taxonomies are for. Structure is what post types are for. Four tests separate them, and a candidate should pass at least three.
The four tests
- Its own fields. The thing carries attributes a post does not have: a start date, a location, a salary band, a linked person, a file. If every attribute maps cleanly onto title, body, featured image and a category, you have a post with a category.
- Its own listing. Somebody has designed, or will design, a page that lists these with their own sorting, filtering and empty state. “We will link to them from the footer” is not a listing.
- Its own lifecycle. They are drafted, reviewed, published, expired and retired on a rhythm that is not the blog’s rhythm, often by people who never touch the blog.
- Its own exclusion. They must stay out of something: the main feed, the site search, the RSS, the category archives. Wanting content excluded is a strong signal. Wanting it grouped differently is not.
What passes, in practice
Job openings pass every test: they have a closing date, they belong on a careers listing, they expire, and they must never appear in the blog. Case studies pass, usually: they carry a client sector, a service line and an outcome section, and they want their own index. Courses, staff profiles, locations, downloadable resources and product specification sheets pass for the same reasons.
Testimonials almost never pass, and they are registered constantly. A testimonial has no archive anybody would visit, no lifecycle beyond “still true”, and no reason to hold a URL. It is a field on something else, or a block, or at most a reusable pattern. The same goes for team members on a site with five of them, FAQs that only ever appear on one page, and “features” that exist to fill a three-column section.
When you actually wanted a taxonomy
A taxonomy is right when the thing behaves like an adjective. Industry, region, difficulty level, technology, service line, format: these describe records rather than being records. If the entry would never carry a body worth reading, it is a term, and modelling it as a post type gives you an editing screen that will always feel too big for what it holds.
There is one argument for a taxonomy that outranks all the others, and it is routinely missed. A taxonomy can be attached to several post types at once. Register “sector” against case studies, articles and job openings, and you get one vocabulary and one filter that works across all three. Three separate custom fields give you three lists that drift apart within a year, and a reporting problem nobody wants to own.
Hierarchical or flat
Hierarchical taxonomies behave like categories: parents, children, checkboxes, a controlled list. Flat ones behave like tags, a free text box that anyone can add to. The choice looks cosmetic and is not. A flat taxonomy on a site with several editors will accumulate near-duplicate terms within months, because “Fintech”, “FinTech” and “Financial services” all look reasonable at the moment of typing.
Our default is hierarchical and closed, with terms added deliberately, even when nothing nests. It costs an editor one extra request. It saves a cleanup that involves merging terms, redirecting term archives and re-checking every filter that referenced them.
Every public taxonomy publishes an archive URL for every term. Two hundred terms where most hold one or two entries means roughly two hundred thin pages: live, crawlable and linked from your own templates. Decide at registration time which taxonomies are public and which exist only for internal filtering. That flag is far cheaper to set than to unwind.
When the answer is neither
The third case is the one that gets skipped. Sometimes the content is one page with several structured sections, and the correct model is a page with fields, or a set of blocks, or a reusable pattern. “Our process” does not need five post types. A pricing table does not need a post type per tier. A homepage does not need a post type called “hero”.
Every post type that nobody fills carries a standing cost: an admin menu item, an empty archive returning a page with nothing on it, weight in every export and backup, and one more thing an inheriting developer has to reverse-engineer. If you have ever audited a WordPress site you just inherited, you have met the site with eleven registered post types, four of which are populated. Nobody remembers what the other seven were for, and nobody removes them, because removing them means proving a negative.
The honest limitation of our bias here: it is a bias, and it costs something real. A team that starts with a page and later needs a genuine post type pays for a migration that would have been free on day one. We have been wrong in that direction more than once. The reason we still lean this way is that the error is recoverable when you were too simple and much less recoverable when you were too clever, and content tells you when it has outgrown a page. Speculation about future structure almost never tells you anything.
The registration decisions that are hard to reverse
Most arguments passed to register_post_type are cosmetic. Change the menu icon, the labels, the menu position, and nobody notices. A handful are effectively permanent, because they are written into stored data or into public URLs. Those are the ones worth an argument before launch.
| Decision | Why it sticks | What we do |
|---|---|---|
| Post type key | Stored on every entry; renaming needs a data migration | Short, lowercase, singular, well under twenty characters |
| Rewrite slug | It is the public URL; changing it needs redirects | Pick the slug the site would still want in three years |
| Where it is registered | Theme registration disappears with the theme | A plugin or must-use plugin, never functions.php |
| hierarchical | Changes the editing screen and the query behaviour | True only when entries genuinely nest under each other |
| has_archive | Creates or withholds a public URL and its template | Decide alongside the search plan, before launch |
| show_in_rest | Controls the block editor and every API consumer | True unless there is a stated reason it should not be |
| capability_type | Switching later means migrating roles and capabilities | Leave it as posts unless permissions genuinely differ |
The key and the slug are not the same thing
People conflate these constantly, and the consequences differ enormously. The key is the internal identifier stored against every entry. The slug is the string in the URL. You can change the slug and survive it, provided you publish redirects from the old paths and update anything that hard-coded them. Changing the key is a database operation that touches every row of that type, plus any meta, term relationship or saved query that referenced the old value.
If you do change a slug after launch, treat the redirect map as the deliverable rather than an afterthought. Permanent redirects preserve the value of links other people wrote, which is the part you cannot rebuild yourself. Keep the map in version control beside the registration code, for the same reason you would keep marketing content under version control: the history is the only reliable answer to “when did this change, and why”.
Register it where it will survive
Post types registered in a theme’s functions.php are the most common trap on inherited sites. Switch the theme, or rebuild the front end, and every entry becomes invisible: still in the database, absent from the admin, missing from the front end, indistinguishable from data loss to anyone who is not a developer. Registration belongs in a small plugin that outlives redesigns, and that plugin belongs in the same maintenance scope as everything else that must not silently rot.
What “supports” quietly decides
The supports array looks like a checklist and behaves like a policy. Omit revisions and you have decided this content has no history, which is a strange decision for anything legally sensitive. Omit the excerpt and every archive card has nothing to show, so somebody writes a truncation helper that cuts a sentence in half. Remove editor support after content exists and that content is stranded in a field no template renders. Turn things on generously at the start. Turning them off later is the direction that loses work.
A newly registered post type returns 404s on its own URLs until permalink rules are regenerated. Do it on plugin activation, or by visiting the permalinks screen once. Never flush on every page load: it is expensive, it hides the real problem, and it makes an intermittent bug look like a caching issue for a week.
The archive template trap
Here is the trap, stated plainly. Enabling the archive publishes a URL. WordPress then looks for a template named for that post type, and when it does not find one it falls back to the generic archive template, then to the index template. So the archive exists, it is live, it is crawlable, and it is being rendered by a layout designed for the blog.
The symptoms are always the same. The listing shows ten entries because that is the blog setting. Events sort newest first, when a visitor wants the next one. Pagination works until page two, which 404s. Filtering does not exist. And the page carries whatever heading the blog template hard-coded, which is often the word “Blog”.
Three ways it goes wrong
First: the archive is left disabled, the designer draws a listing page anyway, and the link in the navigation returns a 404 on launch day. Second: the archive is enabled and somebody also creates a page with the same slug, so one of them silently wins and the other becomes unreachable, usually discovered by an editor who insists they published something. Third: the archive works, gets indexed, and turns out to be a thin duplicate of a curated page that also exists.
Registering a post type publishes URLs. If nobody has designed those URLs, you have shipped pages you did not write.
Decide indexability before launch
Every archive and every term archive needs an explicit answer to one question: should this be indexable. A curated listing you wrote copy for, yes. A term archive holding two entries and no introduction, probably not. This is ordinary technical SEO work, and the platform makes it easy to get wrong by default, which is exactly why we treat archive decisions as part of registration rather than as cleanup afterwards. The wider set of platform defaults worth overriding is covered in our piece on technical SEO on WordPress.
Where the content model really lives
A post type is a container. The model is the fields inside it, and that is where the value or the pain actually accumulates. Content stored as typed fields, with a date that is a date and a price that is a number, can be re-rendered into a new design, sorted, filtered and syndicated. Content stored as one freeform editor field full of pasted markup can only be reformatted by hand, page by page, for as long as the site exists.
This is the argument for deciding the fields before the registration code, not after. Write the list: what every entry must have, what it may have, and what is genuinely optional. Then run the list back through the four tests. Half the post types we are asked to build stop being post types during that hour, which is the cheapest possible moment for them to stop.
Two platform details matter here. Post types reach the block editor and the REST API only when they are registered with REST support, so an integration, a mobile client or a decoupled front end depends on a flag somebody set months earlier. And if the model matters more than the admin screen, the container question changes shape entirely: a workspace like Acrosite takes a different route, giving editors structured fields, then generating the files, committing them to GitHub and triggering the configured deployment. Whether that suits you is the headless or traditional question, and it is worth asking before you model rather than after.
Whichever container you land on, the workflow question sits beside it and is not answered by it. Where a draft lives, who can see it, and what is allowed to stop it are separate design decisions, and our parent company has written about them directly in where drafts live and what stops them.
If you have already registered the wrong thing
Most people read this with two or three questionable post types already in production. That is fine, and it is fixable, provided the fix is treated as a data migration rather than a code cleanup. The order matters more than the technique.
- Count what is actually in there. A post type holding four entries after a year is not a slow start. It is a modelling error that the content has already reported to you.
- Take a backup you have restored at least once. An untested backup is a belief rather than a control, and this is precisely the operation that finds out which one you have.
- Decide the target shape before touching data. Post type into taxonomy term, taxonomy into post type, or the whole thing collapsed into one page with fields. Write it down first.
- Move the data, then the meta, then the terms. Entries that change type keep their identifiers, so anything attached to them survives only if you deliberately carry it across.
- Publish the redirects in the same release. Old entry URLs, the old archive, and any term archives you retired. A redirect added a week later has already lost the traffic it was for.
- Remove the registration last. Only once nothing queries it, links to it or expects it in the admin. Deleting the registration first is how content becomes invisible while still occupying the database.
Step two deserves more respect than it usually gets. Migrations like this are why the backup strategy you only value once exists as an argument at all: the day you need it is usually a day you chose to change something on purpose.
The rule we would hand a team with no appetite for any of this: register a post type only when you can name four things without hesitating. Its archive URL. Its template file. Its required fields. The person who will fill it next month. If any one of the four is missing, use a page and a taxonomy, and wait. Content forces the issue reliably, and it does so at a point where you know what the structure should be. Architecture invented in advance forces different issues, later, into a shape you can no longer change.
If you are staring at an admin menu full of post types nobody can justify and want a second opinion on which of them are load-bearing, tell us what is registered and roughly how much content sits behind each one.
Common questions.
What is a custom post type in WordPress?
A custom post type is a content type you register in addition to the built-in posts and pages, such as jobs, courses or case studies. Entries are stored in the same database table as posts but carry their own type identifier, their own editing screen, their own templates and optionally their own archive URL. Registration happens in code, usually inside a small plugin so the type survives a theme change.
When should I use a taxonomy instead of a custom post type?
Use a taxonomy when the thing describes other content rather than being content itself, such as industry, region, format or difficulty. A useful check is whether the entry would ever carry a body worth reading on its own page. Taxonomies also win when the same vocabulary needs to group several different post types, because one taxonomy can be attached to all of them at once.
Can I rename a custom post type after publishing content?
You can change the public URL slug relatively safely, provided you publish permanent redirects from the old paths and update anything that hard-coded them. Changing the internal post type key is harder, because that value is stored on every entry and referenced by queries, meta and term relationships. Treat a key change as a database migration with a tested backup, not as a configuration edit.
Should custom post types be registered in a theme or a plugin?
Register them in a plugin, or a must-use plugin, never in a theme. Registration inside a theme means the post type disappears the moment the theme is switched or rebuilt. The entries remain in the database, but they vanish from the admin and the front end, which looks exactly like data loss to everyone who is not a developer. A small plugin outlives redesigns.
Does every custom post type need an archive page?
No, and enabling one without designing it causes more problems than leaving it off. Enabling the archive publishes a public URL that WordPress renders with the closest matching template, which is often the blog layout. Decide deliberately: build a designed archive template with the right sorting and pagination, or leave the archive disabled and link to a curated page instead.
Why do my custom post type URLs return a 404?
Almost always because the permalink rules have not been regenerated since the type was registered. Visiting the permalinks settings screen once, or flushing the rules on plugin activation, resolves it. The other common cause is a slug collision: a page using the same slug as the post type archive, in which case one of the two wins and the other becomes unreachable without warning.
Do custom post types appear in the WordPress REST API?
Only when they are registered with REST support enabled. That single flag controls whether the type is available to the block editor, to any decoupled front end and to integrations that read content over HTTP. Teams often discover it months later, when an app or an automation cannot see content that is plainly visible in the admin. Set it deliberately at registration time.
Facing this in your
own business?
Tell us where you’re headed — we’ll map the shortest honest route.