WordPress’s WP_Query covers most querying needs, but it generates SQL through several abstraction layers that prevent certain optimisations: window functions, GROUP BY aggregations, multi-table joins, or UNION queries. The $wpdb global object exposes a direct connection to the WordPress database and provides prepared statement support via $wpdb->prepare(), making raw SQL safe while keeping queries flexible. $wpdb->prepare() uses %s for strings, %d for integers, and %f for floats — the placeholders follow printf syntax rather than PDO-style named parameters. The get_results() method returns an array of objects by default, but passing ARRAY_A returns associative arrays and ARRAY_N returns numerically indexed arrays — useful for CSV exports or bulk processing. get_var() retrieves a single value, get_row() returns one row, and query() executes an INSERT, UPDATE, or DELETE — choosing the most specific method avoids unnecessary data transfer. The $wpdb->last_error property stores the most recent MySQL error message, which should be checked during development to catch mistyped column names before deploying to production. Enabling SAVEQUERIES in wp-config.php logs all queries with timing data to $wpdb->queries, useful for profiling custom SQL alongside WP_Query calls in a staging environment. Always use $wpdb->prefix when constructing table names — hard-coding the prefix breaks installations where the database prefix differs from the default wp_. The EXPLAIN guide shows how to analyse the execution plan of any $wpdb query by prepending EXPLAIN to the same SQL string. The custom table post is the natural next step when $wpdb queries against wp_postmeta become too slow due to unindexed data patterns. Never construct SQL by concatenating user input directly — even inside a WordPress admin screen, an administrator account can be compromised, so parameterized queries are the only safe approach regardless of the trust level of the executing user.
Problem: WP_Query cannot express complex SQL patterns such as aggregations, multi-table joins, or window functions, leaving developers with no clean way to run optimised custom queries against the WordPress database safely.
Solution: Use $wpdb->prepare() with get_results(), get_var(), or get_row() to run parameterized raw SQL that is safe from injection while taking full advantage of MySQL query capabilities.
global $wpdb;
// 1. Count published posts per category (aggregation WP_Query cannot express efficiently)
$cat_counts = $wpdb->get_results(
$wpdb->prepare(
"SELECT t.name, t.slug, COUNT(tr.object_id) AS post_count
FROM {$wpdb->terms} t
INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
INNER JOIN {$wpdb->term_relationships} tr ON tt.term_taxonomy_id = tr.term_taxonomy_id
INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
WHERE tt.taxonomy = %s AND p.post_status = %s
GROUP BY t.term_id
ORDER BY post_count DESC
LIMIT %d",
'category', 'publish', 20
),
ARRAY_A
);
// 2. Get the latest post for each author (correlated subquery)
$latest_per_author = $wpdb->get_results(
"SELECT p.post_author, p.ID, p.post_title, p.post_date
FROM {$wpdb->posts} p
WHERE p.post_status = 'publish' AND p.post_type = 'post'
AND p.post_date = (
SELECT MAX(p2.post_date)
FROM {$wpdb->posts} p2
WHERE p2.post_author = p.post_author AND p2.post_status = 'publish'
)
ORDER BY p.post_date DESC"
);
// 3. Update a custom field for all posts in a category (bulk UPDATE)
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->postmeta} pm
INNER JOIN {$wpdb->term_relationships} tr ON pm.post_id = tr.object_id
INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
SET pm.meta_value = %s
WHERE tt.term_id = %d AND pm.meta_key = %s",
'reviewed', 42, '_review_status'
)
);
NOTE: Remove SAVEQUERIES from wp-config.php before deploying to production — keeping it enabled forces WordPress to store every query string and its execution time in memory on every request, increasing RAM usage significantly on busy sites.