“Error Establishing a Database Connection”, Diagnosed Properly
Five faults print one sentence, and it names none of them. A one-minute probe that tells credentials, host, a dead server, a full disk and a bad table apart.
Martin Kiel · @mkiel
WordPress internals
- Topic
- INFRA
- Section
- Hosting & Infrastructure
- Published
- 16 OCT 2026
- Reading time
- 8 MIN
- Words
- 2022W
Every visitor gets the same page. The heading is Error establishing a database
connection and the body lists three guesses: wrong username, wrong hostname, server down.
WordPress writes that text when a connection attempt fails, and it writes the same text for faults
that have nothing to do with any of the three. If your database user is fine and MySQL is running,
the page still says it, and you are left with a support queue and no information.
I reproduced the main variants on WordPress 7.1.3 with PHP 8.5.6 and MySQL 9.6.0, all on
localhost, and captured what mysqli reports in each case. The browser output was byte
for byte identical every time. The difference lives in one error string that WordPress does not
show you. Getting that string is the entire diagnosis.
The page is identical because the code path is identical
This is not laziness in core, it is a security decision. wpdb::db_connect() calls
mysqli_real_connect() with the debug constant controlling only whether the error is
silenced, checks for a connection error, and on failure calls
$this->bail() with a fixed message. The real error is not put into the page. Anyone
who could trigger a failed connection could otherwise read your database host, your user name and
the driver's own error text, which is a useful reconnaissance signal. You get the generic page; the
specifics go where only you can read them.
So the first question is not "why did the connection fail". It is "where is the string that says
which way it failed". There are two good answers. The debug log, if you can still boot WordPress
with WP_DEBUG on, and a five-line standalone script if you cannot.
Four faults, four mysqli errors, one page
I pointed a probe at the same local MySQL server with four different configuration files and recorded the raw driver error for each. WordPress was pointed at each of those configs in turn; the page rendered the generic message in all four runs.
| What is actually wrong | Driver result | What the visitor sees |
|---|---|---|
| User or password wrong | errno=1045 Access denied for user 'nobody'@'localhost' (using password: YES) |
Error establishing a database connection |
| Database name wrong or missing | errno=1049 Unknown database 'm_nope' |
Error establishing a database connection |
| Server not listening, or wrong port | errno=2002 Connection refused |
Error establishing a database connection |
| Host unreachable, firewall dropping packets | errno=2003 Can't connect to MySQL server on 'db.example.com' (110) |
Error establishing a database connection |
Three of those four are configuration, and the fix is a line in wp-config.php. The
fourth is not configuration at all, and it is the one that eats an afternoon. If the site worked
yesterday and nobody edited a file, the credentials are fine and you are looking for a server.
One naming detail that matters, and it is a trap. When the credentials are right but the database
does not exist, the connection opens and the failure moves to wpdb::select(). That
function has its own page, Cannot select database, which asks whether the name is
spelled correctly and whether the user has permission on it. In my run the page was still the short
"Error establishing a database connection" one, because that longer branch only fires when the
select fails before the request has reached template_redirect. So the message you get
depends on when in the request the select happens, and you should not use the wording on the page
to tell credentials apart from a missing database. The probe does that, and the driver error does
that, and the page does not.
The one-minute probe
When the site cannot boot, you cannot add code to the site. A standalone PHP file next to
wp-config.php reads the four constants out of it, repeats the connect and
select sequence that wpdb performs, and prints the driver's own error. It needs no
WordPress bootstrap, so it works when everything else does not:
<?php
// db-check.php — drop it beside wp-config.php, run it from the shell, delete it after.
$root = __DIR__;
$src = file_get_contents( $root . '/wp-config.php' );
foreach ( array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST' ) as $key ) {
if ( preg_match( "/define\(\s*'$key'\s*,\s*'([^']*)'\s*\)/", $src, $m ) ) {
define( $key, $m[1] );
}
}
$host = DB_HOST;
$port = 3306;
if ( str_contains( $host, ':' ) ) {
list( $host, $part ) = explode( ':', $host, 2 );
if ( ctype_digit( $part ) ) { $port = (int) $part; }
}
mysqli_report( MYSQLI_REPORT_OFF );
$dbh = mysqli_init();
$started = microtime( true );
if ( ! @mysqli_real_connect( $dbh, $host, DB_USER, DB_PASSWORD, null, $port, null, 0 ) ) {
printf( "connect errno=%d %s (%.0f ms)\n",
mysqli_connect_errno(), mysqli_connect_error(), ( microtime( true ) - $started ) * 1000 );
exit( 1 );
}
printf( "connect ok (%.0f ms)\n", ( microtime( true ) - $started ) * 1000 );
if ( ! @mysqli_select_db( $dbh, DB_NAME ) ) {
printf( "select_db errno=%d %s\n", mysqli_errno( $dbh ), mysqli_error( $dbh ) );
exit( 2 );
}
$res = mysqli_query( $dbh, 'SELECT COUNT(*) FROM wp_options' );
if ( ! $res ) {
printf( "query errno=%d %s\n", mysqli_errno( $dbh ), mysqli_error( $dbh ) );
exit( 3 );
}
printf( "wp_options rows=%d\n", mysqli_fetch_row( $res )[0] );
Run it and read the first line that starts with a failure. On my local server the four runs
printed exactly the four strings in the table above, and the elapsed time was the second useful
signal. Access denied and Connection refused came back in single-digit
milliseconds: the server answered and said no. A run that hangs for fifteen seconds and then
reports errno=2003 is a network path dropping packets, which is a firewall or a
security group, not MySQL.
Two caveats about the probe. It reads constants with a regular expression, so a config that
computes the value, pulls it from an environment variable, or uses a variable for
DB_HOST will leave the constants undefined and the script will fail at the first
DB_USER: read the file it printed before you blame the database. And it proves the
credentials work for your shell, not for the web server. On a host where PHP-FPM runs as a
different user with a different socket path or a restricted open_basedir, the shell can
connect while the site cannot. If the probe succeeds and the site still fails, the difference is
the process, and the log from the failing request is the only thing that will show it.
Delete the probe
A file that prints your database user name to any HTTP client is a gift to the next scanner that finds it. Run it from the shell, and remove it in the same session. If you must run it through the browser, delete it before you close the tab.
What the log says when WordPress can still boot
With WP_DEBUG true and WP_DEBUG_LOG true, the same fault produces a
line in wp-content/debug.log instead of a silent page. The credentials failure gave
me this, verbatim:
[16-Oct-2026 21:16:45 UTC] PHP Warning: mysqli_real_connect(): (HY000/1045): Access denied for user 'nobody'@'localhost' (using password: YES) in /private/tmp/m-wperrors-lab/wordpress/wp-includes/class-wpdb.php on line 1990
The HY000/1045 is the same code the probe prints, arriving through a different
door. On the runs where the database was missing or the port was wrong, this log line was empty,
because wpdb does not log every branch of the failure. That asymmetry is why I keep
the standalone probe around: it reports all four cases, and the debug log reports some of them.
If you are on a host where the log path is not writable, the equivalent is the PHP error log,
which the host will point you at. What you are looking for is a line containing
mysqli_real_connect or mysqli_select_db. If neither appears and the page
still says the connection failed, the request is dying before that call, which usually means
wp-config.php itself is unparseable. A parse error in the config produces a white page
and, on some hosts, a log line in the server error log rather than the PHP one.
The two faults that are not in wp-config.php
A full disk on the database volume. I could not reproduce this one here, and I
am not going to describe what a page I did not see looks like. What I can tell you is where the
difference shows up, because the connection still succeeds. When a MySQL data volume fills, the
server keeps accepting connections and starts failing writes with errno=28 in its own
error log, so the front page loads, the admin loads, and saving a post fails. The message is not
the connection message at all; it is The MySQL server has gone away or a
Disk full error on write. Check df -h on the database host before you
touch credentials, and check the MySQL error log for 28 specifically. On a managed database the
connection may also be fine while the replica storage is full, which is why the symptom can be
intermittent and write-only.
A corrupted table. This one does not touch the connection at all. I damaged the
index file of a MyISAM test table carrying the WordPress prefix, then read it back through
$wpdb, which is the layer a plugin actually sees:
table_prefix=wp_
last_error=Incorrect key file for table './m_wperrors/wp_corrupted_demo.MYI'; try to repair it
The connection succeeded, the select failed, and the driver's text landed in
wpdb::last_error. The same statement through the mysql client returned
ERROR 126 (HY000) ... try to repair it, and a heavier corruption of the same index made
the server answer Table './m_wperrors/wp_corrupted_demo' is marked as crashed and should be
repaired instead. Either way, the repair path for MyISAM is REPAIR TABLE, which
returned status OK on my test table and brought the keyed lookup back. InnoDB does not
support that syntax; a damaged InnoDB table usually needs a dump and reload, which is a different
afternoon. Before you repair anything, take a dump. Repair tools on a failing disk are how a
recoverable table becomes an unrecoverable one.
Order of operations when the phone is ringing
- Run the probe from the shell. One line, one error code, under a second.
1045: openwp-config.phpand compare the user and password against the database's actual grants. Remember thatlocalhostand127.0.0.1are different users in MySQL's grant tables, and so is the hostname.1049: count the databases. If the name exists, check the user's grants on it; on some hosts the database is prefixed with the account name and the config still has the short name.2002or2003: stop looking at WordPress. Ping the database host, check the port withnc -vz, then read the server's error log. If the response was fast, the server is up and refusing; if it was slow, something is dropping traffic.- Connection fine but saves fail: check disk and the MySQL error log before anything else.
- Connection fine, one screen fails with a query error: find the table named in the message, dump it, then repair.
Two things this does not solve. It does not tell you when a host silently changed the database
password during a migration, because the error is the ordinary 1045 and the cause is a
ticket you have not read. And it does not help at all when the failure is in DNS: a host name that
resolves to the wrong address produces 2003, exactly like a firewall, and the fix is
in the resolver, not the database. When the same credentials work from one machine and not another,
that is the case you have.
What to do on Monday
- Keep a copy of the probe in your runbook, not in the web root. Paste it into a file in the shell when you need it.
- Make sure
WP_DEBUG_LOGis on for a staging copy of production, so you can see the driver error without touching the live site's constants. - Write the database host's error log path next to the web server error log path. The second one tells you the site is fine; the first tells you the disk is not.
- Test the restore path for one table before you need it. A repair command is not a backup strategy.
Martin Kiel · @mkiel
WordPress internals
Writes about the load-bearing parts of WordPress: the options table, hook ordering, WP_Query behaviour and the database work that decides whether a plugin scales or falls over.
Continue