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 |
|
Pagination |
|
Taxonomy |
|
Meta |
|
Dates |
|
Search |
|
Order |
|
Result shape |
|
Caching |
|
Source: Code Reference: WP_Query
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";
Source: Code Reference: WP_Query
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
Source: Code Reference: WP_Meta_Query
What the arguments become:
-
tax_queryclauses taketaxonomy,field(term_idby default, orslug,name,term_taxonomy_id),terms,operator(INby default,NOT IN,AND,EXISTS,NOT EXISTS) andinclude_children(trueby default for hierarchical taxonomies, so "Fiction" also matches its child genres). Slugs are resolved toterm_taxonomy_idvalues before the main query (three term queries), so the posts query joins onlywp_term_relationships. Nested arrays with their ownrelation(ANDorOR) build complex conditions. -
meta_queryclauses takekey,value,compare(=,!=,>,>=,<,<=,LIKE,NOT LIKE,IN,NOT IN,BETWEEN,NOT BETWEEN,EXISTS,NOT EXISTS,REGEXP,NOT REGEXP,RLIKE) andtype(CHARby default,NUMERIC,DECIMAL,SIGNED,UNSIGNED,DATE,DATETIME,TIME,BINARY). Each clause adds a join ofwp_postmeta, andtypebecomes aCAST()of thelongtextvalue. Without'type' => 'NUMERIC','90' > '300'is true because the comparison is between strings. -
Named clauses (
length,published) can be used asorderbykeys. That is the way to sort by one meta value and filter by another; the oldermeta_keyplus'orderby' => 'meta_value_num'form supports only one key. -
date_queryacceptsyear,month,week,day,hour,minute,second,beforeandafter(arrays or any stringstrtotime()understands, here90 days ago, computed when the query runs),inclusive,compare,columnandrelation. It works on the post’s own date columns, not on meta: the publication date inlp_published_onis compared throughmeta_querywith'type' => 'DATE'. -
orderbyaccepts the post columns (ID,author,title,name,date,modified,parent,menu_order,comment_count),rand,relevance,meta_value,meta_value_num,post__inand 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 ) ) );
}
Source: Code Reference: WP_Query
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. Withidsorid=>parentthethe_post()loop is not available. -
'no_found_rows' => truewhenever nothing is paginated.SQL_CALC_FOUND_ROWSmakes the server find every match, not only the first page. -
'update_post_meta_cache' => falseand'update_post_term_cache' => falsewhen 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' => falseonly 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' ) );
}
}
Source: Code Reference: pre_get_posts
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.
Source: Code Reference: query_posts()
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";
Source: Code Reference: WP_User_Query
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 |
|---|---|
|
|
|
|
|
|
Source: Code Reference: WP_Term_Query
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.
Source: Code Reference: WP_Comment_Query
$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 |
|---|---|
|
Table prefix of the current site ( |
|
Full names of the core tables for the current site; never hard-code |
|
Any statement; returns rows affected or selected, |
|
One value, one row, one column, all rows; output type |
|
Write by arrays, with |
|
Placeholders |
|
Escapes |
|
State of the last query |
|
Error output (shown by default when |
|
Server and charset facts for DDL |
Source: Code Reference: wpdb
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.
Source: Code Reference: wpdb::prepare()
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
)
);
}
}
Source: Code Reference: wpdb
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()returns0when the row exists but the value did not change, andfalseonly on an error: test withfalse === $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 isREPLACE INTO: it deletes the old row and inserts a new one, so columns not given fall back to their defaults.INSERT ... ON DUPLICATE KEY UPDATEthroughprepare()keeps them. -
The table name comes from
$wpdb->prefixat call time, so the same code writeswp_2_lp_stockafterswitch_to_blog()(Multisite Development). -
There is no transaction API, but
$wpdb->query( 'START TRANSACTION' ),COMMITandROLLBACKwork 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 ) );
Source: Code Reference: dbDelta()
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 |
|---|---|---|
|
Yes |
|
One column per line |
Yes |
A second column on the same line as another was never added; no error |
Lengths on integer types ( |
Yes, on MariaDB |
|
Defaults that should be compared are quoted ( |
Yes |
|
Two spaces between |
No longer |
One space created the table and caused no duplicate key on later runs |
|
No longer |
|
No backticks around column names |
No longer |
Backticks are stripped before comparing |
Charset and collation from |
Yes |
Without it the table takes the server default, which may not be |
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.
Source: MariaDB Knowledge Base: EXPLAIN
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 ) );
Source: Code Reference: wpdb
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_datewalks thetype_status_dateindex ofwp_postsand stops after 24 entries. Sorting bylp_published_onhas no usable index: MariaDB reads every meta row with that key (a quarter of the table), casts each value toDATE, sorts 20,000 rows in a temporary table and keeps 24, several hundred times slower. A real archive is paginated, soSQL_CALC_FOUND_ROWSis 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 theWP_Querycache with a persistent object cache (reused until a book changes); sort by awp_postscolumn when that is honest (post_datewhen 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_keyand casts thelongtextvalue, while the table plan reads one integer row. The table answers about six times faster here; theGROUP BYof meta queries and the growth ofwp_postmeta(dozens of rows per post from many plugins on real sites) widen the gap.lumen-catalogstill 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 TABLEstatement, whichdbDelta()applies; avoid adding indexes to core tables.meta_value(longtext) cannot be indexed usefully. -
Avoid
'posts_per_page' => -1on unbounded data,'orderby' => 'rand'(a full sort, and never cached),meta_querywithLIKEon values, and largepost__not_inlists; 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.
SQL background: Querying Data (SELECT), Normalization; site caching: Performance and Caching.
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_typeisOFFon the MariaDB 11.8 used here); WordPress’s own caching is the object cache and, since 6.1, theWP_Queryresult cache. -
The Ultimate WordPress Handbook suggests adding an index on
wp_posts.post_name. It already exists:EXPLAINof a lookup by slug on the seeded site used thepost_nameindex with one row examined. -
The same book mentions
WP_USE_EXT_MYSQL. PHP removed the oldmysqlextension in 7.0, and WordPress connects only throughmysqli; the constant has had no effect since 6.4. -
Tutorials that modify the main query with
query_posts()should usepre_get_posts;caller_get_postsbecameignore_sticky_postsin 3.1 andshowpostsbecameposts_per_page;$wpdb->escape()(deprecated in 3.6) andlike_escape()(deprecated in 4.0) are replaced byprepare()andesc_like(). -
Older examples interpolate variables into SQL and quote placeholders (
'%s');prepare()quotes by itself and identifiers use%isince 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 |
Pagination shows the wrong number of pages |
|
Numeric meta sorts as 10, 100, 2 |
|
A custom table was never created or a column is missing, without an error |
A |
Every page load runs |
An integer type without a length on MariaDB, or a schema check on every request without a version option. |
Stale posts after a direct |
No |