WP_Query and the Database API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Almost every page WordPress serves comes from queries built by WP_Query, and plugins read their data back through WP_Query, its sibling query classes or $wpdb. This page, for plugin and theme developers, covers the arguments that shape those queries, changing the query of a request without replacing it, SQL through $wpdb, custom tables, and measuring the result; the tables are described in Database Schema.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, MariaDB 11.8 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 (in beta, due in December 2026) changes nothing described here so far. Every SQL statement, EXPLAIN plan and timing below was produced on a local 7.1.3 site with a seeded catalogue of 20,000 books.

The Lumen Press catalogue used on this page

The examples query the book catalogue of Lumen Press, the fictitious publisher used across this sub-section. The lumen-catalog plugin registers the post type lp_book (archive at /books/), the hierarchical taxonomy lp_genre, the flat taxonomy lp_series and the post meta lp_isbn, lp_pages (integer) and lp_published_on (a Y-m-d string), as shown in Custom Post Types and Taxonomies and Metadata and Custom Fields. Its installer creates the custom table {$wpdb->prefix}lp_stock with dbDelta(); the full src/Installer.php and the schema version option lumen_catalog_db_version are on Plugin Fundamentals. The table is the stock movement history, one row per change (id, book_id, distributor_sku, delta, the resulting quantity and changed_at, with an index on book_id, changed_at); the current level of each book is its lp_stock post meta.

The measurements use the site of Database Schema with lumen-catalog active, seeded reproducibly (mt_srand( 42 )) with six genres, the series "Harbour Lights" on every tenth book, maria.editor and tom.author as alternating authors, 20,000 published books with random lp_pages (60 to 900) and lp_published_on (1990 to 2026), a stock level each in the post meta key lp_stock, as the cron job on WP-Cron and Scheduled Tasks stores it, and 50 comments. For the measurements below the same levels are also copied into a one-row-per-book table {$wpdb->prefix}lp_stock_levels (book_id primary key, quantity), so both storage models can be compared. Scripts marked "Needs SAVEQUERIES" need wp config set SAVEQUERIES true --raw. Timings come from MariaDB 11.8 in Docker on a laptop: compare them with each other, not with a production server.

WP_Query

WP_Query turns an array of query variables into SQL, runs it, and returns WP_Post objects (or IDs). The main groups of arguments:

Group Arguments

Type and status

post_type (a string, an array or any), post_status (when omitted: publish, plus private for a logged-in user allowed to read it), post__in, post__not_in, post_parent, author, name

Pagination

posts_per_page (default: Settings > Reading; -1 returns everything), paged, offset (overrides paged and breaks pagination), nopaging, no_found_rows

Taxonomy

tax_query, or the shortcuts cat, tag and a taxonomy’s query var ('lp_genre' => 'poetry')

Meta

meta_query, or the shortcuts meta_key, meta_value, meta_compare, meta_type

Dates

date_query (on post_date, post_date_gmt, post_modified or post_modified_gmt), year, monthnum, day, m

Search

s, search_columns (6.2: post_title, post_excerpt, post_content)

Order

orderby (a string or an array of column/direction pairs), order

Result shape

fields (all, ids, id=>parent), ignore_sticky_posts, suppress_filters

Caching

cache_results (6.1), update_post_meta_cache, update_post_term_cache, lazy_load_term_meta, update_menu_item_cache (6.1)

A query with taxonomy, meta and date conditions

The script below asks for long fantasy or science-fiction books that belong to no series, were published (according to lp_published_on) since 2020 and were added to the catalogue in the last 90 days, newest publication first:

<?php
// Run: wp eval-file book-query.php
$query = new WP_Query(
	array(
		'post_type'      => 'lp_book',
		'post_status'    => 'publish',
		'posts_per_page' => 12,
		'paged'          => 1,
		'tax_query'      => array(
			'relation' => 'AND',
			array( 'taxonomy' => 'lp_genre', 'field' => 'slug', 'terms' => array( 'fantasy', 'science-fiction' ) ),
			array( 'taxonomy' => 'lp_series', 'operator' => 'NOT EXISTS' ),
		),
		'meta_query'     => array(
			'relation'  => 'AND',
			'length'    => array( 'key' => 'lp_pages', 'value' => array( 300, 600 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ),
			'published' => array( 'key' => 'lp_published_on', 'value' => '2020-01-01', 'compare' => '>=', 'type' => 'DATE' ),
		),
		'date_query'     => array(
			array( 'column' => 'post_date_gmt', 'after' => '90 days ago', 'inclusive' => true ),
		),
		'orderby'        => array( 'published' => 'DESC', 'title' => 'ASC' ),
	)
);

printf( "found_posts=%d max_num_pages=%d post_count=%d\n", $query->found_posts, $query->max_num_pages, $query->post_count );
while ( $query->have_posts() ) {
	$query->the_post();
	printf( "%s  %s  %d pages\n", get_post_meta( get_the_ID(), 'lp_published_on', true ), get_the_title(), get_post_meta( get_the_ID(), 'lp_pages', true ) );
}
wp_reset_postdata();

echo $query->request, "\n";
found_posts=82 max_num_pages=7 post_count=12
2026-08-18  Book 17634  577 pages
2026-06-11  Book 17648  593 pages
2026-04-06  Book 18682  464 pages
...

$query->request holds the SQL that ran (indentation condensed here):

SELECT SQL_CALC_FOUND_ROWS wp_posts.ID
FROM wp_posts
LEFT JOIN wp_term_relationships ON (wp_posts.ID = wp_term_relationships.object_id)
INNER JOIN wp_postmeta ON ( wp_posts.ID = wp_postmeta.post_id )
INNER JOIN wp_postmeta AS mt1 ON ( wp_posts.ID = mt1.post_id )
WHERE 1=1
  AND ( wp_posts.post_date_gmt >= '2026-07-30 15:18:44' )
  AND ( wp_term_relationships.term_taxonomy_id IN (2,6)
        AND NOT EXISTS (
          SELECT 1 FROM wp_term_relationships
          INNER JOIN wp_term_taxonomy
            ON wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id
          WHERE wp_term_taxonomy.taxonomy = 'lp_series'
            AND wp_term_relationships.object_id = wp_posts.ID ) )
  AND ( ( wp_postmeta.meta_key = 'lp_pages'
          AND CAST(wp_postmeta.meta_value AS SIGNED) BETWEEN '300' AND '600' )
        AND ( mt1.meta_key = 'lp_published_on' AND CAST(mt1.meta_value AS DATE) >= '2020-01-01' ) )
  AND wp_posts.post_type = 'lp_book' AND ((wp_posts.post_status = 'publish'))
GROUP BY wp_posts.ID
ORDER BY CAST(mt1.meta_value AS DATE) DESC, wp_posts.post_title ASC
LIMIT 0, 12

What the arguments become:

  • tax_query clauses take taxonomy, field (term_id by default, or slug, name, term_taxonomy_id), terms, operator (IN by default, NOT IN, AND, EXISTS, NOT EXISTS) and include_children (true by default for hierarchical taxonomies, so "Fiction" also matches its child genres). Slugs are resolved to term_taxonomy_id values before the main query (three term queries), so the posts query joins only wp_term_relationships. Nested arrays with their own relation (AND or OR) build complex conditions.

  • meta_query clauses take key, value, compare (=, !=, >, >=, <, <=, LIKE, NOT LIKE, IN, NOT IN, BETWEEN, NOT BETWEEN, EXISTS, NOT EXISTS, REGEXP, NOT REGEXP, RLIKE) and type (CHAR by default, NUMERIC, DECIMAL, SIGNED, UNSIGNED, DATE, DATETIME, TIME, BINARY). Each clause adds a join of wp_postmeta, and type becomes a CAST() of the longtext value. Without 'type' => 'NUMERIC', '90' > '300' is true because the comparison is between strings.

  • Named clauses (length, published) can be used as orderby keys. That is the way to sort by one meta value and filter by another; the older meta_key plus 'orderby' => 'meta_value_num' form supports only one key.

  • date_query accepts year, month, week, day, hour, minute, second, before and after (arrays or any string strtotime() understands, here 90 days ago, computed when the query runs), inclusive, compare, column and relation. It works on the post’s own date columns, not on meta: the publication date in lp_published_on is compared through meta_query with 'type' => 'DATE'.

  • orderby accepts the post columns (ID, author, title, name, date, modified, parent, menu_order, comment_count), rand, relevance, meta_value, meta_value_num, post__in and named clauses.

$query->found_posts and max_num_pages come from the extra SELECT FOUND_ROWS() that SQL_CALC_FOUND_ROWS enables. The query fetches only IDs: since 6.1, WP_Query caches the ID list under a key derived from the arguments and the last_changed time of posts (and terms), then loads the posts with SELECT wp_posts.* ... WHERE ID IN (...). With a persistent object cache, a repeated query costs no posts query until a post or term changes.

Asking for less: fields, no_found_rows and the cache flags

By default a query also counts all matches and preloads the meta and terms of the posts it returns, because a template will probably print them. Code that only needs IDs (a list of related books, an export, a REST field) can switch that off. The script below runs the same genre query both ways from a cold cache and counts the queries each one caused:

<?php
// Needs SAVEQUERIES. Run: wp eval-file lean-query.php
global $wpdb;

$args = array(
	'post_type'      => 'lp_book',
	'posts_per_page' => 12,
	'tax_query'      => array( array( 'taxonomy' => 'lp_genre', 'field' => 'slug', 'terms' => 'fantasy' ) ),
);
$lean = array(
	'fields'                 => 'ids',  // IDs only: no post objects are built.
	'no_found_rows'          => true,   // No SQL_CALC_FOUND_ROWS and no FOUND_ROWS() query.
	'update_post_meta_cache' => false,  // Do not preload the meta of the results.
	'update_post_term_cache' => false,  // Do not preload their terms.
);

foreach ( array( 'default' => array(), 'lean' => $lean ) as $label => $extra ) {
	wp_cache_flush(); // Same cold start for both runs.
	$before = count( $wpdb->queries );
	$query  = new WP_Query( array_merge( $args, $extra, array( 'cache_results' => false ) ) );
	$run    = array_slice( $wpdb->queries, $before );
	printf( "%-8s queries=%d time=%.2f ms\n", $label, count( $run ), 1000 * array_sum( array_column( $run, 1 ) ) );
}
default  queries=9 time=5.03 ms
lean     queries=5 time=3.63 ms

Listing $wpdb->queries shows what differs. Both runs reload the autoloaded options after the cache flush and resolve the genre slug (three term queries). The default run then adds the SQL_CALC_FOUND_ROWS select, SELECT FOUND_ROWS(), SELECT wp_posts.* ... WHERE ID IN (...), one wp_postmeta query and one term query for the twelve posts; the lean run stops after a plain SELECT wp_posts.ID. The rule of thumb:

  • 'fields' => 'ids' when only IDs are needed. With ids or id=>parent the the_post() loop is not available.

  • 'no_found_rows' => true whenever nothing is paginated. SQL_CALC_FOUND_ROWS makes the server find every match, not only the first page.

  • 'update_post_meta_cache' => false and 'update_post_term_cache' => false when the results' meta or terms are not read. Leave them on otherwise: reading meta of twelve posts without the preload costs twelve queries instead of one.

  • 'cache_results' => false only for one-off queries whose cache entry would never be reused.

The main query and pre_get_posts

For every front-end request, WP::main() parses the URL into query variables and runs the main query, the WP_Query stored in the globals $wp_query and $wp_the_query; the template hierarchy and conditional tags (is_post_type_archive(), is_tax()) read it. Every other WP_Query is a secondary query. In a block theme the Query Loop block with "Inherit query from template" displays the main query; without it, the block builds a secondary query, whose variables the query_loop_block_query_vars filter (6.1) can change.

To change what a page lists, change the variables of the main query before it runs, with the pre_get_posts action. The Lumen Press catalogue sorts the book archive and the genre and series archives by publication date, 24 books per page. The class lives in lumen-catalog/src/Queries.php and is booted from Plugin::boot() (Queries::boot();):

<?php
// lumen-catalog/src/Queries.php
namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Shapes the main query of the book archive and the genre and series archives. */
final class Queries {

	/** Called from Plugin::boot(). */
	public static function boot(): void {
		add_action( 'pre_get_posts', array( self::class, 'adjust_book_archives' ) );
	}

	/** Newest publication date first, 24 books per page, on /books/ and its taxonomy archives only. */
	public static function adjust_book_archives( \WP_Query $query ): void {
		if ( is_admin() || ! $query->is_main_query() ) {
			return;
		}
		if ( ! $query->is_post_type_archive( 'lp_book' ) && ! $query->is_tax( array( 'lp_genre', 'lp_series' ) ) ) {
			return;
		}

		$query->set( 'posts_per_page', 24 );
		$query->set( 'meta_query', array( 'published' => array( 'key' => 'lp_published_on', 'type' => 'DATE' ) ) );
		$query->set( 'orderby', array( 'published' => 'DESC', 'title' => 'ASC' ) );
	}
}

WP::main() accepts extra query variables, so wp eval can run the main query the way a request to /books/ does, then a secondary query in the same request:

wp eval '$GLOBALS["wp"]->main( array( "post_type" => "lp_book" ) );
global $wp_query;
printf( "main: %s, %d per page\n  %s\n", var_export( $wp_query->is_main_query(), true ), $wp_query->get( "posts_per_page" ), preg_replace( "/\s+/", " ", strstr( $wp_query->request, "ORDER BY" ) ) );
$q = new WP_Query( array( "post_type" => "lp_book", "posts_per_page" => 3 ) );
printf( "secondary: %s, %d per page\n  %s\n", var_export( $q->is_main_query(), true ), $q->get( "posts_per_page" ), preg_replace( "/\s+/", " ", strstr( $q->request, "ORDER BY" ) ) );'
main: true, 24 per page
  ORDER BY CAST(wp_postmeta.meta_value AS DATE) DESC, wp_posts.post_title ASC LIMIT 0, 24
secondary: false, 3 per page
  ORDER BY wp_posts.post_date DESC LIMIT 0, 3

The guards are the important part. pre_get_posts fires for every WP_Query, including secondary queries, widgets, REST requests, feeds and the admin list tables. is_admin() keeps the Books screen in the dashboard ordered as the user chose; is_main_query() keeps related-book lists and the Query Loop block untouched; the conditional methods are called on $query, not as global functions, because the globals may still describe another query. The callback receives the object by reference, so it calls $query->set() and returns nothing. The section Measuring queries shows what this particular ordering costs.

Older code changes the main query with query_posts() in a template. That discards the query WordPress already ran, runs a second one, replaces $wp_query and breaks pagination and conditional tags; the Code Reference states that it is not intended for plugins or themes. The theme side of this, secondary loops and wp_reset_postdata(), is covered on Classic Theme Anatomy and The Loop.

get_posts and the other query classes

get_posts() is a thin wrapper: it builds a WP_Query, forces 'ignore_sticky_posts' => true, 'no_found_rows' => true and (by default) 'suppress_filters' => true, accepts numberposts (default 5) as an alias of posts_per_page, and returns the array of posts. suppress_filters disables the posts_* SQL filters, but pre_get_posts still runs, so the guards above matter here too. Users, terms and comments have their own query classes with the same design: an argument array, generated SQL in request, caching (cache_results since 6.3 for users and 6.4 for terms) and a fields argument.

<?php
// Run: wp eval-file other-queries.php
// get_posts(): a WP_Query with no_found_rows, ignore_sticky_posts and suppress_filters forced on.
// A taxonomy's query var ('lp_genre' => 'poetry') is a shortcut for a one-term tax_query.
$poetry = get_posts( array( 'post_type' => 'lp_book', 'numberposts' => 3, 'lp_genre' => 'poetry', 'orderby' => 'title', 'order' => 'ASC' ) );
echo 'get_posts: ', implode( ', ', wp_list_pluck( $poetry, 'post_title' ) ), "\n";

$authors = new WP_User_Query( array( 'has_published_posts' => array( 'lp_book' ), 'fields' => array( 'ID', 'user_login' ), 'orderby' => 'display_name', 'count_total' => false ) );
foreach ( $authors->get_results() as $user ) {
	printf( "user %d %s\n", $user->ID, $user->user_login );
}

$genres = new WP_Term_Query( array( 'taxonomy' => 'lp_genre', 'orderby' => 'count', 'order' => 'DESC', 'number' => 3 ) );
foreach ( $genres->terms as $term ) {
	printf( "genre %s: %d books\n", $term->slug, $term->count );
}

$comments = new WP_Comment_Query( array( 'post_type' => 'lp_book', 'status' => 'approve', 'number' => 2, 'orderby' => 'comment_date_gmt' ) );
foreach ( $comments->comments as $comment ) {
	printf( "comment %d on \"%s\" by %s\n", $comment->comment_ID, get_the_title( $comment->comment_post_ID ), $comment->comment_author );
}
$count = new WP_Comment_Query( array( 'post_type' => 'lp_book', 'status' => 'approve', 'count' => true ) );
echo 'approved book comments: ', $count->get_comments(), "\n";
get_posts: Book 00005, Book 00018, Book 00022
user 2 maria.editor
user 3 tom.author
genre science-fiction: 3431 books
genre crime: 3366 books
genre history: 3346 books
comment 51 on "Book 00050" by Reader 50
comment 50 on "Book 00049" by Reader 49
approved book comments: 50
Class Arguments worth knowing

WP_User_Query (also get_users())

role, role__in, capability, capability__in and capability__not_in (5.9), has_published_posts, meta_query, search with search_columns, fields, number, paged, count_total (set false when the total is not shown), blog_id on Multisite

WP_Term_Query (also get_terms())

taxonomy, object_ids (terms of given posts), hide_empty (default true), parent, child_of, meta_query, orderby (name, count, slug, term_order, include, meta_value), fields (all, ids, names, slugs, count, id=>name, all_with_object_id), update_term_meta_cache

WP_Comment_Query (also get_comments())

post_id, post_type, post_status, status (approve, hold, all), type, type__in, user_id, parent, hierarchical, count, number, no_found_rows, update_comment_meta_cache

Since 6.9 WP_Comment_Query leaves out the note type (the block editor’s editorial Notes, stored as comments) unless type asks for notes or all.

$wpdb

$wpdb, the global instance of the wpdb class, holds the connection opened from the DB_* constants in wp-config.php (always mysqli). Use it when no API fits: a custom table, a report that aggregates across tables, a bulk update. For posts, terms, users, comments, meta and options, use the APIs, because they sanitize, fire hooks and, above all, invalidate caches: after a direct UPDATE of wp_posts, clean_post_cache() must be called or cached copies (and, since 6.1, cached WP_Query results) stay stale.

Member Use

$wpdb->prefix, $wpdb->base_prefix

Table prefix of the current site (wp_, wp_2_ on a Multisite sub-site) and of the network

$wpdb->posts, postmeta, terms, term_relationships, users, options…

Full names of the core tables for the current site; never hard-code wp_

query( $sql )

Any statement; returns rows affected or selected, true for DDL, false on error

get_var(), get_row(), get_col(), get_results()

One value, one row, one column, all rows; output type OBJECT (default), OBJECT_K (keyed by the first column), ARRAY_A or ARRAY_N

insert(), replace(), update(), delete()

Write by arrays, with %s/%d/%f formats; they prepare values themselves and return rows affected or false

prepare( $sql, ...$args )

Placeholders %s, %d, %f, %i (identifier, 6.2) and %%

esc_like()

Escapes % and _ in the user part of a LIKE pattern, before prepare()

insert_id, rows_affected, num_rows, last_query, last_error

State of the last query

show_errors(), hide_errors(), suppress_errors()

Error output (shown by default when WP_DEBUG and WP_DEBUG_DISPLAY are on)

get_charset_collate(), has_cap(), db_version()

Server and charset facts for DDL

Placeholders and prepare

$wpdb->prepare() takes sprintf()-style placeholders and never needs quotes around them: %s is quoted and escaped, %d and %f are cast, %i is wrapped in backticks (WordPress 6.2; check $wpdb->has_cap( 'identifier_placeholders' ) when the plugin supports older versions) and %% is a literal percent sign. IN lists need one placeholder per value, LIKE patterns put the wildcards in the argument after esc_like(), and identifiers chosen by a user (a sort column) still need an allow-list: %i stops injection, not an unexpected column. A call without placeholders triggers a _doing_it_wrong notice. Attacks and fixes: Security for Developers.

A repository for the stock history

lumen-catalog/src/Stock.php gathers every access to {$wpdb->prefix}lp_stock in one class, so that the rest of the plugin never builds SQL. Recording a movement also updates the lp_stock post meta, which stays the one place that holds the current level:

<?php
// lumen-catalog/src/Stock.php: data access for the {prefix}lp_stock movement history.
namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Records and reads stock movements. The current level of a book is its lp_stock post meta;
 * the table keeps one row per change. Every value reaches SQL through a placeholder.
 */
final class Stock {

	/** Full table name for the current site: wp_lp_stock, or wp_2_lp_stock on a sub-site. */
	public static function table(): string {
		global $wpdb;
		return $wpdb->prefix . 'lp_stock';
	}

	/** Sets the current level and logs the change. Movement ID, 0 when the level did not change, false on error. */
	public static function record( int $book_id, int $quantity, string $sku = '' ) {
		global $wpdb;
		$quantity = max( 0, $quantity );
		$previous = (int) get_post_meta( $book_id, 'lp_stock', true );
		if ( $quantity === $previous && metadata_exists( 'post', $book_id, 'lp_stock' ) ) {
			return 0;
		}
		$inserted = $wpdb->insert(
			self::table(),
			array(
				'book_id'         => $book_id,
				'distributor_sku' => sanitize_text_field( $sku ),
				'delta'           => $quantity - $previous,
				'quantity'        => $quantity,
				'changed_at'      => current_time( 'mysql', true ),
			),
			array( '%d', '%s', '%d', '%d', '%s' )
		);
		if ( false === $inserted ) {
			return false;
		}
		update_post_meta( $book_id, 'lp_stock', $quantity );
		return (int) $wpdb->insert_id;
	}

	/** The newest movement of a book as an associative array, or null when it has none. */
	public static function last_movement( int $book_id ): ?array {
		global $wpdb;
		return $wpdb->get_row(
			$wpdb->prepare(
				'SELECT id, book_id, distributor_sku, delta, quantity, changed_at FROM %i
				WHERE book_id = %d ORDER BY changed_at DESC, id DESC LIMIT 1',
				self::table(),
				$book_id
			),
			ARRAY_A
		);
	}

	/** Rows changed (0 when every movement already had this SKU), or false on error. wpdb::update() prepares the values. */
	public static function set_sku( int $book_id, string $sku ) {
		global $wpdb;
		return $wpdb->update(
			self::table(),
			array( 'distributor_sku' => sanitize_text_field( $sku ) ),
			array( 'book_id' => $book_id ),
			array( '%s' ),
			array( '%d' )
		);
	}

	/** Net change per book since a UTC date, keyed by book ID; books without movements are absent. */
	public static function net_changes( array $book_ids, string $since ): array {
		global $wpdb;
		$book_ids = array_values( array_filter( array_map( 'absint', $book_ids ) ) );
		if ( ! $book_ids ) {
			return array();
		}
		$placeholders = implode( ', ', array_fill( 0, count( $book_ids ), '%d' ) );
		$rows         = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT book_id, SUM(delta) AS net FROM %i WHERE changed_at >= %s AND book_id IN ($placeholders) GROUP BY book_id", // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				self::table(),
				$since,
				...$book_ids
			),
			OBJECT_K
		);
		return array_map( static fn( $row ) => (int) $row->net, $rows );
	}

	/** Published books whose stock fell the most since a UTC date, largest fall first. */
	public static function largest_falls( string $since, int $limit = 10 ): array {
		global $wpdb;
		return $wpdb->get_results(
			$wpdb->prepare(
				'SELECT p.ID, p.post_title, SUM(s.delta) AS net FROM %i AS s
				INNER JOIN %i AS p ON p.ID = s.book_id
				WHERE s.changed_at >= %s AND p.post_type = %s AND p.post_status = %s
				GROUP BY p.ID, p.post_title HAVING net < 0
				ORDER BY net ASC, p.post_title ASC LIMIT %d',
				self::table(),
				$wpdb->posts,
				$since,
				'lp_book',
				'publish',
				$limit
			)
		);
	}
}

Exercised with wp eval-file on a copy of the site whose books 4, 5 and 6 ("The Lighthouse Ledger", "The Glass Orchard", "The Quiet Lighthouse") had the levels 12, 183 and 3 (last_movement( 4 ), net_changes() and largest_falls() printed with wp_json_encode(), comments added):

int(1)                                      # record( 4, 7, 'DST-LEDGER-01' ): movement ID 1, delta -5
int(0)                                      # record( 4, 7 ) again: the level did not change, nothing logged
int(1)                                      # set_sku( 4, 'DST-LEDGER-02' )
int(0)                                      # the same again: nothing changed, which is not an error
{"id":"1","book_id":"4","distributor_sku":"DST-LEDGER-02","delta":"-5","quantity":"7","changed_at":"2026-10-28 19:53:14"}
string(1) "7"                               # get_post_meta( 4, 'lp_stock', true ): the current level
{"4":-5,"5":-13}                            # after record( 5, 170 ) and record( 6, 0 ); unknown IDs are absent
[{"ID":"5","post_title":"The Glass Orchard","net":"-13"},{"ID":"4","post_title":"The Lighthouse Ledger","net":"-5"},{"ID":"6","post_title":"The Quiet Lighthouse","net":"-3"}]
NULL                                        # get_var() of a query naming a missing column
last_error=Unknown column 'nope' in 'SELECT'
  • update() returns 0 when the row exists but the value did not change, and false only on an error: test with false === $result, never with ! $result.

  • A failed read returns null (or an empty array), like "no row": check $wpdb->last_error.

  • replace() looks like an upsert but is REPLACE INTO: it deletes the old row and inserts a new one, so columns not given fall back to their defaults. INSERT ... ON DUPLICATE KEY UPDATE through prepare() keeps them.

  • The table name comes from $wpdb->prefix at call time, so the same code writes wp_2_lp_stock after switch_to_blog() (Multisite Development).

  • There is no transaction API, but $wpdb->query( 'START TRANSACTION' ), COMMIT and ROLLBACK work on InnoDB (Transactions).

  • Every column comes back as a string ("quantity":"7", "net":"-13"), whatever its SQL type: cast before comparing.

  • Results of custom tables are not cached by WordPress: wrap hot reads in the object cache and delete the key on write (Options, Transients and Object Cache).

Custom tables and dbDelta

dbDelta(), in wp-admin/includes/upgrade.php (require_once it outside the admin), compares CREATE TABLE statements with the live tables and runs only what is missing: the table, new columns, changed types or quoted defaults, new indexes. It returns messages describing what it did; with $execute = false it only reports. Core uses it on every upgrade, and the lumen-catalog upgrade routine on Plugin Fundamentals calls it.

The script below applies a statement, the same statement again and an extended one to a throwaway table:

<?php
// Run: wp eval-file dbdelta-demo.php
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';

$table = $wpdb->prefix . 'lp_dbdelta_demo';
$v1    = "CREATE TABLE {$table} (
  book_id bigint(20) unsigned NOT NULL,
  quantity int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY  (book_id)
) {$wpdb->get_charset_collate()};";
$v2    = str_replace(
	'  PRIMARY KEY  (book_id)',
	"  reserved int(11) NOT NULL DEFAULT 0,\n  PRIMARY KEY  (book_id),\n  KEY quantity (quantity)",
	$v1
);

foreach ( array( 'v1' => $v1, 'v1 again' => $v1, 'v2' => $v2, 'v1 after v2' => $v1 ) as $label => $sql ) {
	echo $label, ': ', wp_json_encode( dbDelta( $sql ) ), "\n";
}
echo $wpdb->get_var( $wpdb->prepare( 'SHOW CREATE TABLE %i', $table ), 1 ), "\n";
$wpdb->query( $wpdb->prepare( 'DROP TABLE %i', $table ) );
v1: {"wp_lp_dbdelta_demo":"Created table wp_lp_dbdelta_demo"}
v1 again: []
v2: {"wp_lp_dbdelta_demo.reserved":"Added column wp_lp_dbdelta_demo.reserved","0":"Added index wp_lp_dbdelta_demo KEY `quantity` (`quantity`)"}
v1 after v2: []
CREATE TABLE `wp_lp_dbdelta_demo` (
  `book_id` bigint(20) unsigned NOT NULL,
  `quantity` int(11) NOT NULL DEFAULT 0,
  `reserved` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`book_id`),
  KEY `quantity` (`quantity`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci

The second call is a no-op, which is why an upgrade routine can safely run it twice. The last call shows the other side: dbDelta() never drops a column or an index. Removing or renaming one takes an explicit ALTER TABLE in its own migration step.

The formatting rules, tested

The Plugin Handbook lists strict formatting rules. Testing each one against 7.1.3 and MariaDB 11.8 shows that some are now tolerated, while others still fail silently, with an empty result and no error:

Rule Still needed? Observed in 7.1.3

CREATE TABLE in upper case

Yes

create table ... returned an empty array and created nothing

One column per line

Yes

A second column on the same line as another was never added; no error

Lengths on integer types (int(11))

Yes, on MariaDB

qty int produced "Changed type of …​ from int(11) to int" on every run, an ALTER TABLE per upgrade. MySQL 8.0.17+ drops display widths and core ignores the difference there (6.1)

Defaults that should be compared are quoted (DEFAULT '5')

Yes

DEFAULT 5 was not compared, so a changed default was never applied; DEFAULT '5' was

Two spaces between PRIMARY KEY and the column list

No longer

One space created the table and caused no duplicate key on later runs

KEY rather than INDEX

No longer

INDEX qty (qty) is normalized to KEY; no duplicate index

No backticks around column names

No longer

Backticks are stripped before comparing

Charset and collation from $wpdb->get_charset_collate()

Yes

Without it the table takes the server default, which may not be utf8mb4

Following the tolerated rules costs nothing; the lp_stock statement on Plugin Fundamentals follows all of them.

When a custom table beats postmeta

The Plugin Handbook’s position is that post meta is the preferred storage and a table is the exception. Meta is the right choice for per-post attributes that are read with the post, edited in the block editor, exposed through REST or Block Bindings, exported with WXR, revisioned or handled by other plugins. A custom table earns its cost when:

  • the data is queried, filtered or sorted by value, across many rows (stock levels, prices, statistics);

  • rows are numerous and not one-per-post (event logs, loan history, page views), which would bloat wp_postmeta;

  • columns need real types, NOT NULL, composite or unique indexes, or atomic updates (quantity = quantity - 1);

  • the data is written by a background process (the distributor sync) and does not need editor revisions or hooks.

A table costs migrations, uninstall cleanup, per-site Multisite handling and its own caching, and gets no REST, export or privacy exporter for free. The next section measures the read side.

Measuring queries

SAVEQUERIES makes $wpdb keep every query in $wpdb->queries as array( sql, seconds, caller, start time, custom data ); it costs memory on every request, so it belongs on local and staging sites (Debugging, Site Health and Troubleshooting). The Query Monitor plugin shows the same data per page view, grouped by component and caller. EXPLAIN in front of a SELECT shows the execution plan: table order, access type (const, eq_ref, ref, range, index, and ALL for a full scan), the index chosen and the estimated rows.

Two measurements on the seeded catalogue

The script below times four queries (median of eleven runs, IDs only, no row count) and prints their plans: the archive order set by the pre_get_posts callback above against the default post_date order, and "the 20 fantasy books with the fewest units under 5" asked of the lp_stock meta key through WP_Query and of the one-row-per-book lp_stock_levels comparison table through $wpdb:

<?php
// Needs SAVEQUERIES. Run: wp eval-file measure.php
global $wpdb;

// Median time of a query over 11 runs, then its EXPLAIN plan.
$measure = static function ( string $label, callable $run ) use ( $wpdb ) {
	$ms = array();
	for ( $i = 0; $i < 11; $i++ ) {
		$wpdb->queries = array();
		$run();
		$ms[] = 1000 * end( $wpdb->queries )[1];
	}
	sort( $ms );
	printf( "%s: median %.2f ms\n", $label, $ms[5] );
	foreach ( $wpdb->get_results( 'EXPLAIN ' . end( $wpdb->queries )[0], ARRAY_A ) as $row ) {
		printf( "  %-21s %-6s %-16s %-6s %s\n", $row['table'], $row['type'], $row['key'], $row['rows'], $row['Extra'] );
	}
};
$books = array( 'post_type' => 'lp_book', 'cache_results' => false, 'no_found_rows' => true, 'fields' => 'ids' );

// 1. The archive of the pre_get_posts example, then the default order.
$measure( 'archive by lp_published_on', static fn() => new WP_Query( $books + array(
	'posts_per_page' => 24,
	'meta_query'     => array( 'published' => array( 'key' => 'lp_published_on', 'type' => 'DATE' ) ),
	'orderby'        => array( 'published' => 'DESC', 'title' => 'ASC' ),
) ) );
$measure( 'archive by post_date', static fn() => new WP_Query( $books + array( 'posts_per_page' => 24 ) ) );

// 2. The 20 fantasy books with the fewest units under 5: post meta, then the lp_stock_levels comparison table.
$measure( 'low stock from postmeta', static fn() => new WP_Query( $books + array(
	'posts_per_page' => 20,
	'tax_query'      => array( array( 'taxonomy' => 'lp_genre', 'field' => 'slug', 'terms' => 'fantasy' ) ),
	'meta_query'     => array( 'stock' => array( 'key' => 'lp_stock', 'value' => 5, 'compare' => '<', 'type' => 'NUMERIC' ) ),
	'orderby'        => array( 'stock' => 'ASC', 'ID' => 'ASC' ),
) ) );
$sql = $wpdb->prepare(
	'SELECT p.ID FROM %i AS s
	INNER JOIN %i AS p ON p.ID = s.book_id
	INNER JOIN %i AS tr ON tr.object_id = p.ID AND tr.term_taxonomy_id = %d
	WHERE s.quantity < %d AND p.post_type = %s AND p.post_status = %s
	ORDER BY s.quantity ASC, p.ID ASC LIMIT 20',
	$wpdb->prefix . 'lp_stock_levels',
	$wpdb->posts,
	$wpdb->term_relationships,
	get_term_by( 'slug', 'fantasy', 'lp_genre' )->term_taxonomy_id,
	5,
	'lp_book',
	'publish'
);
$measure( 'low stock from lp_stock_levels', static fn() => $wpdb->get_col( $sql ) );
archive by lp_published_on: median 54.30 ms
  wp_postmeta           range  meta_key         37844  Using where; Using temporary; Using filesort
  wp_posts              eq_ref PRIMARY          1      Using where
archive by post_date: median 0.10 ms
  wp_posts              ref    type_status_date 19913  Using where; Using index
low stock from postmeta: median 12.87 ms
  wp_term_relationships ref    term_taxonomy_id 3308   Using where; Using index; Using temporary; Using filesort
  wp_posts              eq_ref PRIMARY          1      Using where
  wp_postmeta           ref    post_id          4      Using where
low stock from lp_stock_levels: median 2.16 ms
  tr                    ref    term_taxonomy_id 3308   Using index; Using temporary; Using filesort
  s                     eq_ref PRIMARY          1      Using where
  p                     eq_ref PRIMARY          1      Using where

A second run gave 67.20, 0.26, 16.01 and 3.07 ms. What the plans say:

  • Archive order. Sorting by post_date walks the type_status_date index of wp_posts and stops after 24 entries. Sorting by lp_published_on has no usable index: MariaDB reads every meta row with that key (a quarter of the table), casts each value to DATE, sorts 20,000 rows in a temporary table and keeps 24, several hundred times slower. A real archive is paginated, so SQL_CALC_FOUND_ROWS is added and the count visits every book whatever the order: run with the row count, the same two queries took 93 ms and 12 ms (median of eleven). The options: keep the order and rely on the WP_Query cache with a persistent object cache (reused until a book changes); sort by a wp_posts column when that is honest (post_date when it really is the publication date); or keep the value in an indexed custom table.

  • Stock. Both plans start from the 3,308 fantasy books and look each one up by primary key, but the meta plan reads about four meta rows per book, compares meta_key and casts the longtext value, while the table plan reads one integer row. The table answers about six times faster here; the GROUP BY of meta queries and the growth of wp_postmeta (dozens of rows per post from many plugins on real sites) widen the gap. lumen-catalog still keeps the current level in post meta, next to the book it describes and read with it, and moves the level into a table only if a stock report like this one becomes a hot path.

Indexes and other habits

  • Add an index only for a measured, recurring query, after reading the plan (core indexes: Database Schema). A custom table’s indexes belong in its CREATE TABLE statement, which dbDelta() applies; avoid adding indexes to core tables. meta_value (longtext) cannot be indexed usefully.

  • Avoid 'posts_per_page' => -1 on unbounded data, 'orderby' => 'rand' (a full sort, and never cached), meta_query with LIKE on values, and large post__not_in lists; fetch IDs and filter in PHP when the list is small.

  • Filter category-like values (genre, format) with taxonomies, which join on integer keys, and cache derived results (counts, reports), invalidating them on the hooks that change the data.

Where older books and tutorials are outdated

  • Pro WordPress recommends tuning the MySQL query cache. MySQL removed it in 8.0, and MariaDB disables it by default (query_cache_type is OFF on the MariaDB 11.8 used here); WordPress’s own caching is the object cache and, since 6.1, the WP_Query result cache.

  • The Ultimate WordPress Handbook suggests adding an index on wp_posts.post_name. It already exists: EXPLAIN of a lookup by slug on the seeded site used the post_name index with one row examined.

  • The same book mentions WP_USE_EXT_MYSQL. PHP removed the old mysql extension in 7.0, and WordPress connects only through mysqli; the constant has had no effect since 6.4.

  • Tutorials that modify the main query with query_posts() should use pre_get_posts; caller_get_posts became ignore_sticky_posts in 3.1 and showposts became posts_per_page; $wpdb->escape() (deprecated in 3.6) and like_escape() (deprecated in 4.0) are replaced by prepare() and esc_like().

  • Older examples interpolate variables into SQL and quote placeholders ('%s'); prepare() quotes by itself and identifiers use %i since 6.2.

  • The dbDelta rules copied from old tutorials are partly obsolete and partly silent traps (table above). Full list: Where Older Books and Tutorials Are Outdated.

Common errors and pitfalls

Symptom Cause and fix

The dashboard’s Books list, a widget or a REST response changed order with the archive

The pre_get_posts callback lacks the is_admin() or is_main_query() guard.

Pagination shows the wrong number of pages

offset was set (it overrides paged), or 'no_found_rows' => true on a paginated query, or posts_per_page changed in the template instead of pre_get_posts.

Numeric meta sorts as 10, 100, 2

orderby meta_value or a clause without 'type' => 'NUMERIC'; use meta_value_num or a typed named clause.

A custom table was never created or a column is missing, without an error

A dbDelta() formatting trap (lower-case create table, two columns on a line) or upgrade.php not loaded.

Every page load runs ALTER TABLE

An integer type without a length on MariaDB, or a schema check on every request without a version option.

Stale posts after a direct UPDATE of wp_posts

No clean_post_cache(); prefer wp_update_post().