Hooks and the data layer
Custom Tables and the $wpdb API in Practice
When a custom table beats postmeta, how dbDelta behaves, why identifiers need their own escaping, and the maintenance you inherit with a table.
Creating the table is the easy part. Fifty lines of SQL in an activation callback, a
dbDelta() call, and it exists. What arrives afterwards is what gets plugins into
trouble: the second migration, the site built by importing a database from somewhere else,
the multisite network where your table exists on one site out of forty, and the uninstall
that leaves orphaned rows because nobody wrote the cleanup.
Adding a table is a decision to run a small piece of infrastructure. That is sometimes the
right call. It is a bad call when you make it because a meta_query felt clumsy,
and a very bad call when you make it with a hard-coded wp_ prefix.
postmeta stops working when the query, not the data, is the problem
wp_postmeta is a key-value store with a composite index on
(post_id, meta_key) and a separate index on meta_key. For a single
post's metadata that is close to ideal: one indexed lookup returns everything attached to
that post.
The trouble starts when you filter across posts, and it comes from the schema. A query for
"posts where the rating is at least 4 and the source is field-notes, ordered by rating" needs
one join per meta key, and because values are stored as text, the numeric comparison becomes
CAST(meta_value AS SIGNED), which no index can serve. The ordering then forces a
temporary table and a sort on top of that.
-- What a two-key meta_query with an ordering sends to MySQL.
SELECT SQL_CALC_FOUND_ROWS wp_posts.*
FROM wp_posts
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 )
INNER JOIN wp_postmeta AS mt2 ON ( wp_posts.ID = mt2.post_id )
WHERE 1=1
AND ( wp_postmeta.meta_key = 'wpsifero_rating'
AND ( ( mt1.meta_key = 'wpsifero_rating' AND CAST(mt1.meta_value AS SIGNED) >= '4' )
AND ( mt2.meta_key = 'wpsifero_source' AND mt2.meta_value = 'field-notes' ) ) )
AND ( wp_posts.post_type = 'post' AND wp_posts.post_status = 'publish' )
GROUP BY wp_posts.ID
ORDER BY wp_postmeta.meta_value+0 DESC
LIMIT 0, 10
On a test install with 20,000 posts and 40,000 meta rows, that query ran at a mean of
19.4 ms over fifty runs. The equivalent selection against a purpose-built table with a
composite index ran at 3.7 ms, and both returned one row. The plans show why: the meta
version ends in a sort of a materialised temporary table, while the table version does a
range scan on (source, rating) and builds nothing.
| postmeta | Custom table | |
|---|---|---|
| Single post, all its metadata | One indexed lookup. Hard to beat | One indexed lookup. Same cost |
| Filter across posts on two keys | One join per key, plus a temporary table for the sort | One range scan on a composite index |
| Typed values and numeric comparison | Text column, cast at query time, index unusable | Real column types, indexes as designed |
| Data volume | Shares a table every other plugin writes to | Isolated, and you can prune it |
| Cost you take on | Nothing beyond the API | Migrations, uninstall cleanup, multisite rollout |
Those numbers come from one machine and a synthetic dataset, so do not carry them to a production estimate. The structural point generalises: a schema that stores every value as text and every attribute as a row serves a single-record read beautifully and a cross-record filter badly. Stay with postmeta while your access pattern is per-post and the volume is modest. Move when you are filtering, sorting, aggregating or counting across records, or when one plugin's rows have grown enough to compete with everything else in a shared table.
dbDelta is a parser, and it only notices two kinds of change
dbDelta() compares the CREATE TABLE statement you pass against
the table as it exists, and issues the ALTER TABLE statements needed to close
the gap. What it closes is narrower than people assume: it adds missing columns and it
changes column types. It does not drop a column or an index that is absent from your
statement.
initial: id, keep_me, drop_me indexes: PRIMARY, keep_idx, drop_idx
dbDelta reported: Changed type of wp_wps_drop.keep_me from varchar(32) to varchar(64)
after: id, keep_me, drop_me indexes: PRIMARY, keep_idx, drop_idx
I ran that on a real 7.0.7 install: a column and an index were removed from the statement,
the type change was applied, and nothing was dropped. This is how a schema that has been
through several versions accumulates stray columns. Removing one is a deliberate
ALTER TABLE ... DROP COLUMN behind your own version check, and only worth writing
when no older code is still reading the column.
The formatting rules come from the same place, because dbDelta() reads the
statement as lines of text.
- One field or key definition per line. Everything inside the parentheses is split on
\n, so a field and a key on the same line are treated as one field. - A space after
PRIMARY KEYbefore the parenthesis.PRIMARY KEY (id)andPRIMARY KEY (id)both work,PRIMARY KEY(id)does not. - No
IF NOT EXISTS. The table name is extracted withCREATE TABLE ([^ ]*), soCREATE TABLE IF NOT EXISTS wp_notesyields a table name ofIF, and every run after the first reportsCreated table IFwhile the real table goes untouched. - Bare column names in the examples, with no backticks, even though the parser strips them in most positions.
The spacing rule is the most repeated and least explained, so here is the mechanism. The
parser takes the text before the first space in a line as the field name.
PRIMARY KEY (id) yields PRIMARY, which fails the field-name check
and is handed to the index parser as KEY (id), and that parses. Remove the
space and KEY(id) is not something the index parser accepts, so the run emits
undefined array key warnings from inside upgrade.php and a broken
ALTER TABLE ... ADD `` (``). Two spaces is convention; one space is the
requirement.
Identifiers need escaping too, and %i is not a whitelist
Every mention of $wpdb->prepare() talks about values. Values are half of an
injection surface. A table or column name that comes from configuration, a filter or a
request is an identifier, you cannot put quotes around it, and %s is the wrong
placeholder for it.
WordPress 6.2 added %i for exactly this. It wraps the argument in backticks,
doubles any backtick inside it, and passes it as an identifier.
global $wpdb;
$table = $wpdb->prefix . 'wpsifero_ratings';
$wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) );
// SELECT COUNT(*) FROM `wp_wpsifero_ratings`
$wpdb->get_results( $wpdb->prepare(
'SELECT * FROM %i WHERE rating >= %d AND source = %s',
$table,
4,
'field-notes'
) );
What it does not do is validate. Feed it a name that does not exist and you get a database error at query time, nothing earlier. Here are two versions of the same mistake, run against the install I have been using:
$input = 'wp_posts WHERE 1=1 -- ';
$wpdb->prepare( 'SELECT COUNT(*) FROM %i', $input );
-> SELECT COUNT(*) FROM `wp_posts WHERE 1=1 -- `
-> WordPress database error: Incorrect table name 'wp_posts WHERE 1=1 -- '
"SELECT COUNT(*) FROM {$input}"
-> 58
The prepared version fails closed: the whole string becomes one quoted identifier, so
there is no table with that name. The interpolated version executes and returns the row count
of wp_posts, having swallowed the rest of the clause in a SQL comment. That is
escaping versus validation, stated as output. If an identifier can be influenced by a user,
resolve it against a fixed list of names your plugin knows and use the value from the list.
%i is for identifiers you have already decided are legitimate.
One more detail, because it costs people hours: %s escapes and adds its own
quotes, so writing WHERE x = '%s' is harmless and you will never notice core
repairing it. And prepare() refuses an argument used as both an identifier and a
value in the same query, which is core catching a category error for you.
$wpdb->prefix is the table name, and wp_ is only its default
WordPress does not require the tables to be called wp_. The starter
wp-config.php defines $table_prefix = 'wp_', some hosts change it,
and multisite appends the site ID. On a network where the main site uses wp_,
the second site's tables are wp_2_posts and wp_2_options, while the
shared wp_users and wp_usermeta stay unnumbered.
$wpdb->prefix is the prefix of the site currently being served.
$wpdb->base_prefix is the prefix of the network's main site, which is what a
network-wide table needs, because it has to exist exactly once. If your SQL string starts
with wp_, none of this reaches you, and the plugin works on the developer's
machine and fails on the first multisite install or the first site with a custom prefix.
global $wpdb;
$per_site = $wpdb->prefix . 'wpsifero_ratings'; // one table per site
$shared = $wpdb->base_prefix . 'wpsifero_index'; // one table for the network
$wpdb->get_blog_prefix(); // e.g. 'wp_2_'
$wpdb->get_blog_prefix( 1 ); // e.g. 'wp_'
$wpdb->base_prefix; // e.g. 'wp_'
The second reason to build the name from $wpdb instead of a constant is that
switch_to_blog() changes the value. A maintenance routine that loops over the
network and switches before running its SQL picks up each site's prefix automatically. One
that captured the prefix once at the top of the function writes every site's rows into the
first site's table.
One prefix, one table, forty sites
On my two-site test network, the ratings table existed on the main site and nowhere
else, because the activation routine had created it once for whichever site was being
served, and the plugin's own option was missing on the second site for the same reason. If
your table is per-site, network activation has to loop over existing sites, and a site
created later needs the same treatment on wp_initialize_site.
Design the index around the query you actually run
An index is a bet about access patterns, so write the query first and then decide what to index. The shape to aim for on a filter you run often is a composite index whose leading column is the equality condition and whose second column is the range or sort condition, because MySQL can use both parts of it and skip the sort.
-- Query: rating >= 4 AND source = 'field-notes', ordered by rating.
KEY source_rating (source, rating)
-- Plan with that index:
-- -> Sort: r.rating DESC, r.post_id
-- -> Index range scan on r using source_rating
-- over (source = 'field-notes' AND 4 <= rating)
Two habits worth having. Give the table a single-column primary key on an
AUTO_INCREMENT column, because updates and deletes against a table with no
primary key get painful at scale. And put a KEY on the column you join or filter
by most often, which for most plugin tables is a foreign key to a post, a user or an order.
An index you never use costs write time and disk. An index you need and do not have costs a
table scan on every request.
You now own migrations, uninstall and the rollout
Everything WordPress normally handles for its own tables becomes your job, and the failures are quiet.
Migrations first. Store a schema version in an option and compare it on a hook, as lesson 3
established. What is new with a table is the second half: dbDelta() adds the
column, and your routine has to backfill existing rows with something sensible. Write the
backfill so it can run twice, because a request that dies halfway through an upgrade leaves
the version option unwritten and the next request tries again.
Then uninstall. The table outlives the plugin unless you drop it, and dropping it is irreversible. Sites do get restored from backups and owners do delete plugins by accident, so the choice worth making deliberately is whether cleanup is the default or the exception. Plenty of plugins ask, with an option or a filter, and delete nothing unless the answer is explicit.
Then multisite, which is the one that catches people late. Decide whether the table is per site or per network before you write the first query, because changing your mind later means moving data between prefixes on a live network. Per site is the safer default for user content; per network suits a shared index. Whichever you pick, activation has to cover the sites that already exist and site creation has to cover the ones that come later, and those are two different code paths.
What none of this buys you is an argument for adding a table early. The cost shows up eighteen months in, when you are running an upgrade routine across forty sites at 2 a.m. Allocate the table when the query pattern demands it, and then treat the schema as a product with a release cycle rather than a detail of the feature that needed it.
Build a ratings table for posts with dbDelta(): an auto-increment
primary key, a post_id, a rating, a source and a
created_at. Then break your own statement on purpose, one change at a time,
and record whether the table was created, whether dbDelta() reported
anything, and what happened on the second run: IF NOT EXISTS, a field and a
key on the same line, and PRIMARY KEY(id) with no space.
Write the query for "the ten highest rated posts from the field-notes source" against
the table, and the same selection as a two-key meta_query. Run
EXPLAIN on both and write down which one sorts a temporary table.
Finally, add a context column the way a real migration would, and answer:
what does dbDelta() do with the existing rows, and what would you have to
write to remove the column again?
Show the solution and the reasoning
Each of the three broken statements fails differently, and none of them raises an error you would notice in production.
IF NOT EXISTS table created, dbDelta reported "Created table IF", second run repeats it
field and key on one line second field never parsed; type and default changes are missed
PRIMARY KEY(id) PHP warnings from upgrade.php, then ALTER TABLE ... ADD `` (``
The full statement, formatted so it can be parsed, looks like this.
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$table = $wpdb->prefix . 'wpsifero_ratings';
$collate = $wpdb->get_charset_collate();
dbDelta( "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
rating tinyint(3) unsigned NOT NULL DEFAULT 0,
source varchar(32) NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY post_rating (post_id, rating),
KEY source_rating (source, rating)
) {$collate};" );
Both indexes earn their place and they serve different queries.
(post_id, rating) answers "the highest rated entries for this post" and
(source, rating) answers "the highest rated entries from this source across
all posts". Swapping the columns in either one would leave that query able to use only the
first column.
On the meta side, the selection needs two INNER JOINs on
wp_postmeta, a CAST for the numeric comparison and a
GROUP BY to collapse the duplicate rows the joins produce. Its
EXPLAIN output ends in a sort over a materialised temporary table. The table
version ends in an index range scan with no temporary table. That contrast is what you are
buying.
Adding context is the migration case. dbDelta() adds the
column and every existing row receives the default you declared, so the second half of the
migration is the backfill. Keep it idempotent, because the version check can run twice.
// After dbDelta() has added the column, in the same upgrade routine.
$wpdb->query( $wpdb->prepare(
'UPDATE %i SET context = %s WHERE context = %s',
$table,
'unknown',
''
) );
Removing the column again is not something dbDelta() will do for you. It
has to be an explicit statement behind your own version check, and for a plugin that ships
to other people the honest answer is usually to leave it. Dropping it breaks any site still
running the older code, and the disk you save is measured in kilobytes.
The limitation to take from this lesson: none of the work above is hard, and all of it repeats on every schema change for the life of the plugin. That is the real price of a custom table, paid in small increments for years.
Progress
Mark this lesson done and it stays marked in this browser.
Course complete
That is the last lesson of WordPress Plugin Development. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.