Custom post types are how WordPress stops being a blog and starts being a content management system. If you've run into the wall where "Posts" and "Pages" don't fit your content anymore - portfolio items, team members, product reviews, case studies - you need custom post types. This article walks you through what they actually are, how to register one with real code, and the specific places where people break them when they first try.
The setup
Custom post types live in your theme's functions.php file or a site-specific plugin. You register them with the register_post_type() function, hooked to init. This does not create new database tables - WordPress stores all post types in the same wp_posts table, differentiated by the post_type column. It also does not add content for you; it creates the container where that content will live.
What a custom post type actually does
When you register a custom post type, you're telling WordPress to treat a specific kind of content differently from regular blog posts. The most familiar example is WooCommerce Products. Every product you add in WooCommerce is stored as a custom post type called product, with its own set of fields for price, SKU, inventory, and variations. Products never show up in your main blog feed because they're registered as a distinct type.
The registration process assigns the post type a machine name (the slug), a plural label for the admin menu, and capabilities like whether it supports featured images, excerpts, or custom fields. You also define whether it appears in search results, what taxonomies attach to it, and what the permalink structure looks like. WordPress stores these custom post types in the same database table as standard posts, but filters them by type when you query.

Here's what makes them different from categories. Categories group existing posts together. Custom post types separate content by type, not topic. A portfolio item and a blog post about design are fundamentally different content structures. The portfolio item needs fields for client name, project date, and project URL. The blog post needs publish date and author. Categories can't handle that distinction.
Registering a custom post type with code
You add this code to your theme's functions.php file or, better, a site-specific plugin. The function runs on the init hook, which fires after WordPress is fully loaded but before any content is sent to the browser.
function themescorp_register_portfolio() {
$args = array(
'labels' => array(
'name' => 'Portfolio',
'singular_name' => 'Portfolio Item',
'add_new_item' => 'Add New Portfolio Item',
'edit_item' => 'Edit Portfolio Item',
),
'public' => true,
'has_archive' => true,
'rewrite' => array('slug' => 'work'),
'supports' => array('title', 'editor', 'thumbnail', 'excerpt'),
'show_in_rest' => true,
'menu_icon' => 'dashicons-portfolio',
);
register_post_type('portfolio', $args);
}
add_action('init', 'themescorp_register_portfolio');
The first argument, portfolio, is the machine name. Keep it lowercase, no spaces, under 20 characters. This is what WordPress stores in the database and what you use in queries. The second argument is an array of configuration options.
public controls whether the post type appears on the front end and in search. Set it to false if you're building an admin-only content type like internal notes. has_archive creates an archive page at yoursite.com/work (based on the rewrite slug) that lists all items. supports defines which meta boxes appear in the editor - title, editor (the main content area), thumbnail, excerpt, custom fields, comments, and more. show_in_rest enables the block editor; without it, you're stuck with the classic editor.
After you add this code, visit Settings > Permalinks in your dashboard and click Save Changes. You don't need to change anything - just saving flushes the rewrite rules so WordPress recognizes the new URLs. If you skip this step, your custom post type pages will return 404 errors.
Adding custom fields to store specialized data
Custom post types become powerful when you attach custom fields. A portfolio item needs fields for client name, project date, and external URL. A testimonial needs fields for the person's job title and company. You can add these with register_meta(), Advanced Custom Fields, or Pods.
Here's how to register a simple text field for client name:
function themescorp_register_portfolio_meta() {
register_meta('post', 'client_name', array(
'object_subtype' => 'portfolio',
'type' => 'string',
'single' => true,
'show_in_rest' => true,
));
}
add_action('init', 'themescorp_register_portfolio_meta');
object_subtype limits this field to the portfolio post type. show_in_rest exposes it to the block editor. To display it on the front end, use get_post_meta() in your template:
$client = get_post_meta(get_the_ID(), 'client_name', true);
if ($client) {
echo '<p>Client: ' . esc_html($client) . '</p>';
}
Plugins like Custom Post Type UI make this registration visual - you fill out a form instead of writing code. It's faster for non-developers, but the plugin adds overhead and you lose control over edge cases. Custom Post Type UI and Pods are the most common plugins for this task, but code gives you version control and doesn't disappear if you deactivate a plugin.
What breaks
URLs return 404 even though the post type is registered
You added the code, the admin menu appears, you can create items, but visiting the permalink gives a 404. The cause is stale rewrite rules. WordPress builds a table of URL patterns when you register a post type, and it caches that table. If you add the post type code but don't flush the cache, WordPress doesn't know the new URLs exist.
The fix: go to Settings > Permalinks and click Save Changes. You don't need to modify any settings - the save action triggers flush_rewrite_rules(). If you're debugging in code, you can call flush_rewrite_rules() manually, but never leave it in production code because it hits the database on every page load.
Custom fields don't appear in the block editor
You registered meta fields with register_meta(), but they don't show up in the editor sidebar. The cause is usually missing show_in_rest. The block editor is built on the REST API, so any meta field you want to edit in Gutenberg must expose itself to that API.
The fix: add 'show_in_rest' => true to your register_meta() call. If the field still doesn't appear, check that you set 'single' => true - the REST API doesn't auto-display fields that return arrays. For complex layouts, use a plugin like Advanced Custom Fields, which handles REST exposure and renders input controls automatically.
The archive page displays but uses the wrong template
Your custom post type archive at /work/ shows the content, but it looks like a blog archive with dates and authors. WordPress falls back to archive.php or index.php when no custom template exists. Custom post types don't auto-generate templates.
The fix: create a template file named archive-{post-type}.php in your theme directory. For the portfolio example, that's archive-portfolio.php. Copy your theme's archive.php as a starting point, then modify the loop to display portfolio-specific fields. For single items, create single-portfolio.php. WordPress checks for these files first before falling back to generic templates.
FAQs
Can I convert existing posts to a custom post type?
Yes, by changing the post_type value in the database. Run a SQL query like UPDATE wp_posts SET post_type = 'portfolio' WHERE ID IN (123, 456, 789), replacing the IDs with your target posts. The Post Type Switcher plugin does this through the admin interface if you're not comfortable with SQL. Be aware that taxonomies and meta fields don't auto-migrate - you'll need to reassign categories or map custom fields manually.
Do custom post types slow down my site?
No. They use the same database queries as regular posts. The performance difference comes from how many items you query and whether you cache the results. A poorly written query that pulls 500 portfolio items with all meta fields on every page load will be slow, but that's a query problem, not a post type problem. Use WP_Query with reasonable post counts and enable object caching if you're serving high traffic.
Can I use custom post types with page builders?
Yes. Elementor, Beaver Builder, and Divi all support custom post types as long as you set 'supports' => array('elementor') or the builder's equivalent in your registration arguments. Most builders auto-detect post types with 'public' => true and 'show_in_rest' => true. You can then design single-item templates and archive layouts using the builder's interface instead of PHP templates.
What happens to custom post types if I switch themes?
If you registered the post type in functions.php, it disappears when you switch themes. The content stays in the database, but the admin menu vanishes and URLs break. This is why many developers put custom post types in a site-specific plugin instead of the theme. Create a plugin file in wp-content/plugins/ with the registration code, activate it, and it persists across theme changes.
Can I exclude a custom post type from search results?
Yes. Set 'exclude_from_search' => true in the register_post_type() arguments. This keeps the post type out of the default WordPress search but still lets you query it manually with WP_Query. If you want finer control - like excluding it from front-end search but keeping it in admin search - use the pre_get_posts filter to modify the query conditionally based on is_admin() or is_search().
Verdict: Use code in a site-specific plugin if you need version control, theme independence, and full control over edge cases. Use Custom Post Type UI if you're managing multiple post types for a client and need a visual interface. Flush permalinks immediately after registration or every URL will 404.