WordPress SQL Query Generator

Write a $wpdb query with prepare() used correctly: placeholders for values, an allowlist for table and column names, and the right get_ method.

Live output

Enable JavaScript to customise; default output below.

Insert, update and delete have helper methods that escape for you, which is safer than writing the SQL.

Core tables come from $wpdb properties, which handle the prefix and multisite for you.

Written as $wpdb->prefix . 'name'. Never interpolate a table name from user input.

Naming the columns you need is faster than SELECT * and survives a schema change better.

Live preview query.php
<?php
/**
 * A direct database query.
 *
 * Reach for WP_Query, get_posts() or get_terms() first: they are cached and
 * they respect every filter a plugin has added. This is for the cases they
 * genuinely cannot express.
 */

/**
 * Runs the query.
 *
 * @return mixed
 */
function my_plugin_query() {
	global $wpdb;

	$table = $wpdb->posts;

	// A direct query is not cached by WordPress. Without a persistent object
	// cache this is per request, which still helps on a page that asks twice.
	$key    = 'my_plugin_query';
	$cached = wp_cache_get( $key, 'my_plugin' );

	if ( false !== $cached ) {
		return $cached;
	}

	// The SQL is built with concatenation rather than interpolation, so the
	// only things that reach the query unescaped are written here by you.
	$sql = 'SELECT ID, post_title FROM ' . $table;
	$args = array();

	$sql .= ' WHERE 1 = 1';

	$sql   .= ' AND post_status = %s';
	$args[] = 'publish';

	$sql   .= ' AND post_type = %s';
	$args[] = 'post';

	// A column name cannot be a placeholder, so it is written here and never
	// taken from a request.
	$sql .= ' ORDER BY post_date DESC';

	$sql   .= ' LIMIT %d';
	$args[] = 20;

	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built above from literals.
	$result = $wpdb->get_results( $wpdb->prepare( $sql, $args ) );

	wp_cache_set( $key, $result, 'my_plugin', 5 * MINUTE_IN_SECONDS );

	return $result;
}

Output is valid and updates as you type.

Write a $wpdb query with prepare() used the way it is meant to be used: placeholders for values, table and column names written by you, and the get_ method that matches what you want back.

How to use

  1. Check first that WP_Query, get_posts() or get_terms() cannot do it. They are cached, they respect every filter a plugin added, and they do not go stale when the schema changes.
  2. Put values in placeholders: %s, %d, %f. That is what prepare() escapes.
  3. Write table and column names yourself. They cannot be placeholders, so anything from a request must be checked against an allowlist first.
  4. Set a limit. A query with no limit against wp_postmeta is one of the classic ways to take a site down.
  5. Cache the result. A direct query is not cached by WordPress at all, and wrapping it is usually the whole performance fix.

Example

global $wpdb;

$sql  = 'SELECT ID, post_title FROM ' . $wpdb->posts;
$sql .= ' WHERE post_status = %s AND post_type = %s';
$sql .= ' ORDER BY post_date DESC LIMIT %d';

$rows = $wpdb->get_results( $wpdb->prepare( $sql, 'publish', 'post', 20 ) );

Building the SQL by concatenation rather than interpolation is deliberate: with "… {$table} …" it is one careless edit before a variable that came from a request ends up in the query.

Pitfalls

  • prepare() escapes values, not identifiers. ORDER BY %s does not work, and a column name from $_GET is an injection whatever you do with it.
  • Quoting a placeholder yourself, as '%s', breaks the escaping: prepare() adds the quotes.
  • esc_like() exists because % and _ are wildcards in LIKE. Without it, a search for 100% matches everything.
  • $wpdb->prefix is the current site’s prefix. On multisite, $wpdb->base_prefix is the network one, and using the wrong one reads another site’s data.
  • $wpdb->get_results() loads every row into memory. A limit is not optional on a large table.
  • Direct queries bypass every filter, so a plugin that adds conditions to post queries has no effect on yours.
  • $wpdb->insert() and $wpdb->update() escape for you. Hand-written INSERT statements are where most injection bugs in WordPress plugins live.
  • $wpdb->last_error is the only place a failed query reports itself. A silent false return is easy to miss.

Compatibility

$wpdb, prepare(), get_results(), get_row(), get_var(), insert() and update() have all been stable since WordPress 3.0. esc_like() needs 4.0, and passing an array of arguments to prepare() needs 4.9. The wp_cache_* functions work without a persistent cache, where they are per request. The generated code targets PHP 7.0 and up, and the tool runs entirely in your browser.

Frequently asked questions

When should I use $wpdb at all?
When the query genuinely cannot be expressed as a WP_Query or a term or user query: a report, an aggregate, or your own table.
How do I make ORDER BY safe?
Check the column against an allowlist you control, then concatenate it. There is no placeholder for identifiers.
Why is my LIKE matching everything?
The search term contains % or _. Run it through $wpdb->esc_like() first.
Does this work on multisite?
$wpdb->prefix gives the current site’s tables. For network-wide tables, use $wpdb->base_prefix.
How do I see why a query failed?
$wpdb->last_error after the call, and $wpdb->last_query for the SQL that actually ran.

From the people who built this tool

WP Adminify

The WordPress admin, rebuilt: a dashboard worth looking at, menu and column control, a real file manager and the login page your client sees.

See WP Adminify Free version on WordPress.org

Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.