Posts vs Pages
Posts and pages are both rows in wp_posts — hierarchy, taxonomies and archives are the only real differences.
get_post_type( int|WP_Post|null $post = null ): string|falseWordPress registers two content types for you: post and page. Both are rows in the wp_posts table, both come back as WP_Post objects, and the post_type column is the only thing separating them. Every behavioural difference comes from the arguments core passed to register_post_type() for each one.
What actually differs
Posts are chronological. Core registers them with the category and post_tag taxonomies, they fill the blog index and the feeds, and date archives query them. Pages are hierarchical: they support page-attributes, so post_parent nests them into URLs like /about/team/ and menu_order sorts siblings. Pages carry no taxonomies and have no archive of their own, and they can be assigned a page template.
Choosing between them
Use a page when the content's place in the site is a position in a tree — About, Pricing, a nested policy section. Use a post when the item is one of many similar things ordered by time. If it is neither — products, events, staff profiles — it belongs in a custom post type instead of being forced into either.
What trips people up
In the loop, the_title() is the short form of echo get_the_title( get_the_ID() ); both run the the_title filter, the short one just echoes instead of returning. The other surprise is the front page: is_front_page() and is_home() are different tests, and get_pages() returns false outright if you point it at a non-hierarchical post type.
Example
<?php
// Pages and posts are the same table; you query them differently because
// they were registered differently.
// Pages are hierarchical. get_pages() returns WP_Post[] (or false if the
// post type is not hierarchical). Defaults: post_type 'page',
// post_status 'publish', sort_column 'post_title'.
$children = get_pages( array(
'child_of' => get_the_ID(),
'parent' => get_the_ID(), // direct children only; child_of also returns grandchildren
'sort_column' => 'menu_order', // the field the Page Attributes box writes
'sort_order' => 'ASC',
) );
foreach ( $children as $child ) {
printf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink( $child ) ),
// get_the_title( $child ) is the long form of the_title() in the loop.
esc_html( get_the_title( $child ) )
);
}
// Posts are chronological. Same table, ordered by date instead of a tree.
$recent = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
) );
while ( $recent->have_posts() ) {
$recent->the_post();
the_title( '<h2>', '</h2>' ); // shorthand for echo '<h2>' . get_the_title() . '</h2>'
}
wp_reset_postdata();When to use it
- Deciding whether a new "Case Studies" section should be pages, posts, or a custom post type
- Listing the child pages of the current page in a sidebar navigation
- Printing a heading on the blog index when the assigned Posts page's own content is discarded
- Searching posts and pages in one query for a site-wide results template
More examples
Querying posts and pages together
Because both types live in wp_posts, a single WP_Query can return them together — useful for a site-wide search results template where you want static pages listed alongside articles, but need to render dates only for the chronological ones.
// post_type accepts an array, so one query can span both built-in types.
$results = new WP_Query( array(
'post_type' => array( 'post', 'page' ),
's' => $search_term,
'posts_per_page' => 20,
'post_status' => 'publish',
) );
while ( $results->have_posts() ) {
$results->the_post();
// Branch on the type rather than assuming one shape.
if ( 'page' === get_post_type() ) {
echo '<span>Page</span>';
} else {
echo '<span>' . esc_html( get_the_date() ) . '</span>';
}
the_title( '<h3>', '</h3>' );
}
wp_reset_postdata();Telling the front page from the blog index
The single most common source of confusion between the two types. Reach for this when a client's static home page and blog listing need different headers, or when a theme prints the wrong title on the blog archive.
// Settings > Reading writes two options when "A static page" is selected.
$front_page_id = (int) get_option( 'page_on_front' ); // 0 when the front page is the blog
$posts_page_id = (int) get_option( 'page_for_posts' ); // the page whose body is ignored
if ( 'page' === get_option( 'show_on_front' ) && $front_page_id ) {
// A page row is being used as the home page.
$front = get_post( $front_page_id );
}
// In a template, use the conditionals rather than the options:
// is_front_page() - whatever is at the site root, page or blog index
// is_home() - the blog index, wherever it lives
if ( is_home() && ! is_front_page() ) {
// We are on the assigned Posts page. Its post_content is never output;
// print its title manually if the design needs one.
echo '<h1>' . esc_html( get_the_title( $posts_page_id ) ) . '</h1>';
}Building a page breadcrumb from post_parent
Shows the practical consequence of hierarchy: pages have an ancestor chain you can walk, posts do not. Use it in a page.php template, and note the is_post_type_hierarchical() guard so the function degrades quietly on posts.
/**
* Pages nest through post_parent; posts almost never do.
* get_post_ancestors() returns ancestor IDs, closest parent first.
*/
function acme_page_breadcrumb( $post = null ) {
$post = get_post( $post );
if ( ! $post instanceof WP_Post || ! is_post_type_hierarchical( $post->post_type ) ) {
return '';
}
$trail = array();
$ancestors = array_reverse( get_post_ancestors( $post ) ); // top level first
foreach ( $ancestors as $ancestor_id ) {
$trail[] = sprintf(
'<a href="%s">%s</a>',
esc_url( get_permalink( $ancestor_id ) ),
esc_html( get_the_title( $ancestor_id ) )
);
}
$trail[] = esc_html( get_the_title( $post ) );
return implode( ' / ', $trail );
}
Discussion