There are four reliable ways to run a database query in WordPress: phpMyAdmin in your hosting control panel, WP-CLI’s wp db query command, the $wpdb class inside PHP, and a plugin that gives you a SQL console in wp-admin. Which one you pick depends on whether you need a one-off lookup or repeatable code, and on whether your host gives you SSH.
wp db query "SELECT COUNT(*) FROM wp_posts;". Inside a plugin or theme, always use $wpdb with prepare(), never string concatenation. And back up the database before any query that writes.Two things before you start. Know your table prefix, which is set by $table_prefix in wp-config.php and is very often not wp_. And know that WordPress will not save you from a bad query. There is no undo button, no confirmation dialog, and no version history. The database does exactly what you tell it to.
Step 1: Back up, then test on staging
This is not boilerplate. A mistyped DELETE takes two seconds and can wipe every post on the site.
wp db export backup-before-query.sqlIn cPanel without SSH: open phpMyAdmin, select the database in the left sidebar, click the Export tab, keep the Quick method and SQL format, and download the file. Do not skip this because the query “looks fine”. Everyone’s fatal query looked fine.
Better still, run it on a copy first. A local install takes about ten minutes to set up, and our guide to installing WordPress on a local host covers importing a production database into it. Test there, confirm the row count matches what you expect, then run it for real.
UPDATE or DELETE without a WHERE clause. DELETE FROM wp_posts; empties your entire site. Write the query as a SELECT first, check the rows it returns are the ones you meant, and only then swap the SELECT ... for DELETE or UPDATE ... SET, leaving the WHERE untouched.Method 1: phpMyAdmin in cPanel
phpMyAdmin sits in the Databases group of cPanel, alongside MySQL Databases and the Database Wizard. It is the right tool when you want to look at data, run a single query, and get out.
- Open phpMyAdmin and pick your database from the left sidebar. If several are listed, match the name against
DB_NAMEinwp-config.php. - Click the SQL tab.
- Paste your query and click Go.
Read results in the grid, use Export on the results to pull them out as CSV, and use the Search tab on an individual table when you would rather click than type. The downsides: it times out on large tables, it is easy to run something against the wrong database, and there is no dry-run mode.
Method 2: WP-CLI
If your host offers SSH, or cPanel’s Terminal in the Advanced group, this is the best of the four. WP-CLI reads credentials straight from wp-config.php, so there is nothing to configure and no chance of hitting the wrong database.
wp db query "SELECT COUNT(*) FROM wp_posts WHERE post_status = 'publish';"
wp db query "SELECT option_value FROM wp_options WHERE option_name = 'home';" --skip-column-names
wp db query < cleanup.sql
wp db tables
wp db size --tables
wp db checkThe file form is the one worth remembering. Write your statements into a .sql file, review it, keep it in version control, and pipe it in. That gives you a record of exactly what you ran.
WP-CLI also has purpose-built commands that are safer than raw SQL for the jobs people most often write raw SQL for: wp search-replace, wp option, wp post meta and wp transient. Reach for those first.
Method 3: the $wpdb class in PHP
$wpdb is the global database object WordPress uses internally. This is the only correct way to query the database from inside a plugin, a theme, or a snippet.
Reading data
global $wpdb;
// Many rows, as an array of objects.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_type = %s AND post_status = %s
ORDER BY post_date DESC LIMIT %d",
'post',
'publish',
20
)
);
// One value.
$pending = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = '0'"
);
// One row, as an associative array.
$user = $wpdb->get_row(
$wpdb->prepare( "SELECT * FROM {$wpdb->users} WHERE user_email = %s", $email ),
ARRAY_A
);
// One column, as a flat array.
$ids = $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_parent = 0" );Note the table names. $wpdb->posts, $wpdb->postmeta, $wpdb->users and friends are properties that already include the prefix. For your own tables, build the name with $wpdb->prefix . 'my_table'. Hardcoding wp_posts is the single most common reason a plugin breaks on someone else’s site.
prepare() and why unprepared interpolation is a security hole
Dropping a variable straight into a query string is a SQL injection vulnerability. Not “bad practice” or “risky in theory”: a working exploit.
// NEVER. A crafted ?u= value can read or destroy your database.
$rows = $wpdb->get_results(
"SELECT * FROM {$wpdb->users} WHERE user_login = '" . $_GET['u'] . "'"
);
// Correct.
$rows = $wpdb->get_results(
$wpdb->prepare( "SELECT * FROM {$wpdb->users} WHERE user_login = %s", $_GET['u'] )
);The placeholders are %s for strings, %d for integers, %f for floats, and %i for identifiers such as table and column names. Three rules that trip people up: do not put quotes around %s yourself, because prepare() adds them; a literal percent sign has to be written as %%; and a LIKE pattern should be built with '%' . $wpdb->esc_like( $term ) . '%' passed in as a %s.
The insert, update and delete helpers
For writes, skip SQL entirely. These methods escape everything for you.
$table = $wpdb->prefix . 'my_events';
$wpdb->insert(
$table,
array( 'event' => 'import', 'qty' => 42, 'created_at' => current_time( 'mysql' ) ),
array( '%s', '%d', '%s' )
);
$new_id = $wpdb->insert_id;
$wpdb->update(
$table,
array( 'qty' => 43 ), // data
array( 'id' => $new_id ), // where
array( '%d' ), // data formats
array( '%d' ) // where formats
);
$wpdb->delete( $table, array( 'id' => $new_id ), array( '%d' ) );update() and delete() require a where array, so they cannot accidentally hit every row. That alone is a good reason to prefer them over hand-written SQL.
Error handling
$result = $wpdb->query( $sql );
if ( false === $result ) {
error_log( 'Query failed: ' . $wpdb->last_error );
error_log( 'SQL was: ' . $wpdb->last_query );
}query() returns the number of affected rows, true for schema statements, and false on error. Zero is not an error, so test with ===. During development, $wpdb->show_errors() prints problems to the screen; $wpdb->hide_errors() puts the lid back on before you deploy.
Method 4: a plugin-based SQL runner
If you have no SSH and no phpMyAdmin, a plugin can give you a query console inside wp-admin. WP Data Access is the most capable option currently maintained on the plugin directory, with a query builder, a data explorer, import and export, and scheduled queries. Install it, run what you need, and deactivate it afterwards.
For the specific job of rewriting strings across the database, Better Search Replace is a safer choice than raw SQL. It handles serialized data and has a dry-run mode that reports how many fields would change before it touches anything.
Which method should you use?
| Method | Needs | Best for | Repeatable? | Risk |
|---|---|---|---|---|
| phpMyAdmin | cPanel access | One-off reads, browsing tables, exports | No | Medium. No dry run, wrong-database mistakes |
| WP-CLI | SSH or cPanel Terminal | Scripted maintenance, migrations, bulk work | Yes | Low. Reads config, has dry-run commands |
| $wpdb in PHP | Ability to deploy code | Anything shipped in a plugin or theme | Yes | Low with prepare(), high without it |
| SQL plugin | wp-admin access only | Locked-down hosting with no other route | Partly | Medium. Extra attack surface |
Queries worth keeping
Replace wp_ with your real prefix in all of these.
Published posts with no featured image
SELECT p.ID, p.post_title
FROM wp_posts p
LEFT JOIN wp_postmeta pm
ON pm.post_id = p.ID AND pm.meta_key = '_thumbnail_id'
WHERE p.post_type = 'post'
AND p.post_status = 'publish'
AND pm.post_id IS NULL
ORDER BY p.post_date DESC;Find and replace inside post content
-- Look first.
SELECT ID, post_title FROM wp_posts
WHERE post_content LIKE '%old-domain.com%';
-- Then change it.
UPDATE wp_posts
SET post_content = REPLACE(post_content, 'old-domain.com', 'new-domain.com')
WHERE post_content LIKE '%old-domain.com%';This is safe for plain text inside post_content. It is not safe anywhere serialized data lives, which is most of wp_options and a lot of wp_postmeta. More on that below.
Users who have never logged in
Worth knowing before you write this one: WordPress core does not record last login anywhere. There is no last_login column and no core usermeta key for it. Any query claiming to list inactive users is really querying a field some plugin created.
-- Only works if a plugin writes a 'last_login' usermeta key.
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
LEFT JOIN wp_usermeta m ON m.user_id = u.ID AND m.meta_key = 'last_login'
WHERE m.umeta_id IS NULL
ORDER BY u.user_registered DESC;
-- Core-only alternative: registered accounts that never published anything.
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
LEFT JOIN wp_posts p ON p.post_author = u.ID
WHERE p.ID IS NULL
ORDER BY u.user_registered DESC;Delete expired transients
DELETE a, b FROM wp_options a
JOIN wp_options b
ON b.option_name = CONCAT('_transient_', SUBSTRING(a.option_name, 20))
WHERE a.option_name LIKE '_transient_timeout_%'
AND a.option_value < UNIX_TIMESTAMP();That deletes the timeout row and its matching value row together. The WP-CLI equivalent is one word shorter and handles site transients too: wp transient delete --expired. Use that if you can.
Orphaned postmeta
-- Count them first.
SELECT COUNT(*) FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;
-- Then remove them.
DELETE pm FROM wp_postmeta pm
LEFT JOIN wp_posts p ON p.ID = pm.post_id
WHERE p.ID IS NULL;On a site that has been running for years this can free real space. Run the count first so you know what you are about to remove.
Troubleshooting
“Table ‘yourdb.wp_posts’ doesn’t exist”
Your prefix is not wp_. Check $table_prefix in wp-config.php, or run wp db tables. Many installers randomize it to something like wp_k3f9_. On multisite, subsite tables carry the blog ID as well, so site 2’s posts live in wp_2_posts. If the message names the wrong database entirely, you are connected to the wrong one.
The query times out or the page hangs
Large wp_postmeta and wp_options tables are the usual cause, and phpMyAdmin gives up long before the database does. Batch it: add LIMIT 5000 and run it repeatedly until it reports zero rows affected. Better, run it through WP-CLI, which is not bound by PHP’s web request timeout. Put EXPLAIN in front of a slow SELECT to see whether it is using an index at all.
Serialized data broke after a search and replace
This is the classic self-inflicted wound. A serialized PHP string stores the byte length of every string inside it. Change example.com to example.org and nothing breaks, because the lengths match. Change it to my-new-site.com and every affected length prefix is now wrong, PHP cannot unserialize the value, and widgets, theme settings and plugin options quietly reset to defaults.
The fix is to never use SQL REPLACE() on serialized columns. Use WP-CLI instead, which unserializes, replaces, and re-serializes with correct lengths:
wp search-replace 'https://old.example.com' 'https://new.example.com' --dry-run
wp search-replace 'https://old.example.com' 'https://new.example.com' --skip-columns=guid --report-changed-onlyAlways run --dry-run first. Keep --skip-columns=guid, because post GUIDs are identifiers and rewriting them confuses feed readers. If you have already corrupted data this way, restore from the backup you took. There is no repair query.
“Error establishing a database connection” after your query
You did not break the connection with a SELECT. Either you dropped or renamed a table, or the query was heavy enough that the server hit a connection or memory limit and is still recovering. Wait a minute, reload, and check your host’s error log in the Metrics group of cPanel.
You cannot get into wp-admin to install a query plugin
Then a plugin is probably the reason. Deal with that first: our guide to disabling a WordPress plugin from cPanel gets you back into the dashboard without touching SQL.
Frequently asked questions
How do I run a SQL query in WordPress without a plugin?
Use phpMyAdmin from the Databases group in cPanel, or WP-CLI over SSH with wp db query "your SQL here". Both talk to the database directly and need nothing installed inside WordPress. WP-CLI is the better choice if it is available, because it reads your credentials from wp-config.php and cannot hit the wrong database.
Is $wpdb->query() safe to use?
Yes, provided every variable in the SQL goes through $wpdb->prepare() first. Unprepared string concatenation is a SQL injection hole, full stop. For inserts, updates and deletes, prefer the insert(), update() and delete() helpers, which escape values for you and require a where clause.
Where do I find my WordPress table prefix?
Open wp-config.php in the site root and look for the $table_prefix line. It is typically wp_, but many installers randomize it. From the command line, wp db tables lists every table with the prefix already applied.
Can I undo a database query in WordPress?
No. There is no undo, no trash, and no revision history for direct SQL. The only way back is a backup taken before you ran it. Export the database first every time, and keep the export until you have confirmed the site still behaves correctly.
Why did my widgets disappear after a search and replace?
Because widget settings are stored as serialized arrays, and a raw SQL REPLACE() changed a string’s length without updating its length prefix. PHP can no longer unserialize the value, so WordPress falls back to defaults. Restore your backup and redo the replacement with wp search-replace.
How do I query the database from inside a page template?
Declare global $wpdb; at the top of the function, then use $wpdb->get_results() with prepare(). Before you do, check whether WP_Query, get_posts() or get_users() can do the job. They use the object cache and respect filters, so they are faster and better behaved than raw SQL.
Wrapping up
WP-CLI if you have shell access, phpMyAdmin if you do not, $wpdb with prepare() for anything that ships as code, and a plugin only when the first three are unavailable. That ordering will serve you well on any host.
The two habits that matter more than the tool: export the database before every write, and write the query as a SELECT before you turn it into an UPDATE or DELETE. For reference, WordPress documents the wpdb class, the wp db query command and wp search-replace in full. If you are writing queries to audit tracking data, our guides to adding Google Analytics to WordPress and tracking custom events in Google Analytics cover the reporting side of the same problem.

