Skip to content

Get WordPress Post Content by ID: Raw vs Rendered HTML

Tested November 2026 on WordPress 7.1.4 (PHP 8.3).

Getting WordPress post content by ID is simple until you need to display it. The stored content can include HTML, block comments and shortcodes, so retrieving it is not the same as rendering it.

Use get_post_field( 'post_content', $post_id, 'raw' ) when you need the stored content. For a public-only HTML fragment, I decided to use the helper below because it checks visibility and password access, renders with the target post globals and restores the saved globals afterwards.

I tested this on a fresh WordPress 7.1.4 site with PHP 8.3.36, MariaDB 11.8.9 and Twenty Twenty-Five 1.5. I also repeated the block and shortcode rendering checks with Twenty Twenty-One 2.9.

The short answer

  • Retrieve stored content with get_post_field( 'post_content', $post_id, 'raw' ). “Raw” does not mean plain text.
  • Use tp_get_public_post_html() below when you need sanitized HTML from a publicly viewable post, with its post-password check respected.
  • The helper returns a fragment. It does not add that fragment to your page or recreate the target post’s complete request context.

1. Add the public-content helper

There are multiple ways to retrieve the same content, but for displaying another post’s body, I prefer making the access checks and rendering context explicit.

Copy & paste the following code snippet:

<?php
/**
 * Return sanitized body HTML for a publicly viewable post.
 * Unpublished, private, missing and password-locked posts return an empty string.
 * This sets post globals, not the main query or its conditional tags.
 */
function tp_get_public_post_html( $post_id ) {
	if ( ! is_numeric( $post_id ) || (int) $post_id <= 0 ) {
		return '';
	}

	$target = get_post( (int) $post_id );
	if ( ! $target || ! is_post_publicly_viewable( $target ) || post_password_required( $target ) ) {
		return '';
	}

	$keys = array(
		'post', 'id', 'authordata', 'currentday', 'currentmonth',
		'page', 'pages', 'multipage', 'more', 'numpages',
	);
	$saved = array();
	foreach ( $keys as $key ) {
		if ( array_key_exists( $key, $GLOBALS ) ) {
			$saved[ $key ] = $GLOBALS[ $key ];
		}
	}

	try {
		$GLOBALS['post'] = $target;
		setup_postdata( $target );
		$html = apply_filters( 'the_content', $target->post_content );
		return wp_kses_post( str_replace( ']]>', ']]&gt;', $html ) );
	} finally {
		foreach ( $keys as $key ) {
			if ( array_key_exists( $key, $saved ) ) {
				$GLOBALS[ $key ] = $saved[ $key ];
			} else {
				unset( $GLOBALS[ $key ] );
			}
		}
	}
}Code language: PHP (php)

The helper does the following:

  1. Retrieves the target post and returns an empty string if it is missing, not publicly viewable or still password-locked.
  2. Saves the post globals listed in the snippet.
  3. Sets the target post and runs setup_postdata() before applying the the_content filters.
  4. Replaces the closing ]]> marker and sanitizes the result with wp_kses_post().
  5. Restores the saved globals in finally, including when rendering throws an exception.

This is deliberately a public-only helper. It is not a private-content renderer that changes its answer based on an administrator’s permissions.

Where to add this code

Add the snippet with a code snippets plugin instead of your theme’s functions.php, so it survives theme updates and you can switch it off without FTP. My guide to adding code snippets safely shows how, using the free Code Snippets plugin. On my own sites I use WPCodeBox, which keeps snippets in a cloud library so the same snippet can go on several sites. Not sure which plugin to choose? I compared Code Snippets, WPCode and WPCodeBox by feeding each one broken snippets. (Marius)

Disclosure: the WPCodeBox link is an affiliate link. If you buy through it, I earn a commission at no extra cost to you.

2. Choose stored content or rendered HTML

If you are retrieving content for processing rather than displaying it, use get_post_field( 'post_content', $post_id, 'raw' ).

In my classic-content test, this returned the same stored string as the post object’s post_content property and the default get_post_field() call. That string included <strong> markup and blank lines between paragraphs. It was not plain text, and those blank lines had not been converted into paragraph elements.

The stored block content also included block comments and a dynamic post-title block. Retrieving that string is different from rendering the title.

For display, call tp_get_public_post_html() with the target post ID and use its returned string where your template needs the fragment. Supply a known positive integer post ID.

An empty return value can mean the post does not exist or is excluded by the helper’s access policy. It does not necessarily mean the post has an empty body.

3. Check that blocks use the target post

Applying the content filters is only part of the job. A block or shortcode can also read the current post globals.

I tested a post titled “Summer reading” containing a paragraph block, a post-title block and a shortcode that reported the current context. The helper rendered the target title and reported target post 6.

When that content was requested from Sample Page, the shortcode reported target post 6 but queried object 2. That distinction is important: the helper changes the post globals, not the main query.

So a shortcode using the current post ID can receive the target post, while one using the queried object still sees the original request. Conditional tags and loop state are not replaced either.

The same target blocks and shortcode also rendered under Twenty Twenty-One 2.9, using “Summer reading” as the title while retaining the original request’s queried object.

4. Test the content that should not appear

Everything should be working properly, but I would also check the posts that must not be displayed.

In the subscriber request, explicit raw retrieval returned the stored content of the password-locked, private and draft posts. The helper returned an empty string for each.

That is why I would not use raw retrieval as an access check. Being able to retrieve a stored field does not mean it should be shown to the visitor.

The password-protected post became available through the helper after the correct post password was submitted and its cookie was sent with the anonymous request. Private and unpublished content remain outside the helper’s public-only policy.

For missing content, I tested positive ID 999999. get_post() returned null, while both tested field-retrieval variants returned an empty string. The helper returned an empty string for that missing ID and for zero.

Last, but not least, a normal Sample Page request contained neither the diagnostic output nor the restricted-content markers. The helper tests recorded no PHP diagnostics.

What I tried and why I picked this approach

The common answers to this question look similar, but they are not doing exactly the same job.

What I triedWhat happenedWhy I did not pick it as the public HTML helper
Read the post object’s post_contentReturned stored contentRetrieval alone does not render it or check visibility
Retrieve post_content with get_post_field()Returned the stored string in the classic-content testUseful for retrieval, not a complete display solution
Apply the_content filters to the retrieved fieldFormatted classic paragraphs and ran the shortcodeFiltering alone does not establish the target post globals or enforce this helper’s access policy
Retrieve the post object, filter its content and replace ]]>Formatted paragraphs and changed ]]> to ]]&gt;It needs a missing-post check before reading the object’s content
Pass the ID to get_the_content()Left paragraph breaks and the shortcode unprocessedIt did not run the content filters by itself

The object-based filtering approach and filtered-field alternative both formatted the classic paragraphs. Their closing-marker output differed because the object-based version explicitly performed the replacement.

I also tried filtering the result of get_the_content(). Without a front-end request, the shortcode ran, but reported post=0; queried=0; singular=no. Passing an ID into retrieval had not supplied the current-post context the shortcode needed.

For this scenario, I prefer the helper because those decisions are in one place instead of being left to every caller.

What sanitization and cleanup did

I tested stored HTML containing emphasis, an HTTPS link, a script element and an onclick attribute.

The helper preserved the emphasis and link. It removed the script element and event-handler attribute, but the script’s text remained as ordinary paragraph text. Sanitizing HTML is not the same as deleting everything that was inside a removed element.

I also tested a shortcode that threw the exception “Reading shortcode stopped rendering”. The helper restored the tracked globals after both successful rendering and that exception. It propagated the exception rather than returning an empty fallback.

That is the behavior I want to keep visible: denied content returns an empty string, while a rendering failure is still a failure.

Limitations

  • The helper returns body HTML, not a complete page, and does not insert it automatically.
  • It sets post globals, not the main query, conditional tags or loop state.
  • Restoration covers the listed globals, not arbitrary side effects from content filters.
  • wp_kses_post() may remove markup needed by some embeds or plugins. The script and attribute checks were not a full security audit.
  • Pagination, more tags, third-party content plugins, asset loading and caching were not tested.
  • The checks covered returned HTML and server behavior, not browser appearance or JavaScript behavior.

Leave a Reply

Your email address will not be published. Required fields are marked *