WordPress provides two functions for creating and updating posts entirely in PHP, without any user interaction: wp_insert_post() and wp_update_post(). They accept the same array of arguments that mirrors the wp_posts table columns, plus helper keys like meta_input and tax_input for attaching meta and taxonomy terms in the same call. These functions are the building blocks for any feature that programmatically generates content: import scripts, data migration tools, WP-CLI commands that seed test data, plugin activation routines that create default pages (Privacy Policy page is a good example), and any background process that creates posts from external data sources like an API or RSS feed. Understanding the return values, error handling, and the interaction with post status transitions is essential for reliable programmatic post management.
Problem: An import script needs to create new posts from a CSV file — with custom post meta and category assignments — and update existing posts if they have already been imported (identified by a unique source ID stored in post meta).
Solution: Use wp_insert_post() with meta_input and tax_input for new records. Query by the source ID meta to find existing posts and use wp_update_post() to update them.
<?php
/**
* Upsert a post from external data.
* Returns the post ID on success, WP_Error on failure.
*/
function upsert_imported_post( array $data ) {
// Check if this source ID has been imported before
$existing = get_posts( [
'post_type' => 'post',
'meta_key' => '_import_source_id',
'meta_value' => sanitize_key( $data['source_id'] ),
'posts_per_page' => 1,
'post_status' => 'any',
'fields' => 'ids',
'no_found_rows' => true,
] );
$post_args = [
'post_title' => sanitize_text_field( $data['title'] ),
'post_content' => wp_kses_post( $data['content'] ),
'post_status' => 'publish',
'post_type' => 'post',
'post_author' => 1,
'post_date' => sanitize_text_field( $data['date'] ?? current_time( 'mysql' ) ),
// meta_input: key => value pairs, saved via update_post_meta
'meta_input' => [
'_import_source_id' => sanitize_key( $data['source_id'] ),
'_import_url' => esc_url_raw( $data['source_url'] ?? '' ),
],
// tax_input: taxonomy => array of term IDs or slugs
'tax_input' => [
'category' => array_map( 'absint', $data['category_ids'] ?? [] ),
'post_tag' => array_map( 'sanitize_text_field', $data['tags'] ?? [] ),
],
];
if ( ! empty( $existing ) ) {
// Update existing post
$post_args['ID'] = $existing[0];
$result = wp_update_post( $post_args, true ); // true = return WP_Error on failure
} else {
// Create new post
$result = wp_insert_post( $post_args, true ); // true = return WP_Error on failure
}
return $result; // int (post ID) on success, WP_Error on failure
}
// Usage:
$post_id = upsert_imported_post( [
'source_id' => 'ext_42',
'title' => 'Imported Post Title',
'content' => '<p>Post body content here.</p>',
'date' => '2020-10-15 10:00:00',
'category_ids' => [ 3, 7 ],
'tags' => [ 'wordpress', 'import' ],
'source_url' => 'https://source.example.com/posts/42',
] );
if ( is_wp_error( $post_id ) ) {
error_log( 'Import failed: ' . $post_id->get_error_message() );
} else {
error_log( 'Upserted post ID: ' . $post_id );
}
NOTE: tax_input in wp_insert_post() uses wp_set_post_terms() internally, which requires the current user to have the assign_terms capability for the taxonomy. In CLI scripts or background processes where there is no logged-in user, set a user context first with wp_set_current_user(1), or use wp_set_post_terms() directly after the insert. Also, wp_insert_post() fires all transition hooks (save_post, wp_insert_post, status transition actions) — if you are bulk-inserting many posts, temporarily remove expensive hook callbacks to avoid timeouts.