Skip to content
System nominalUTC --:--:--Articles 080

“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
Technical cover graphic for “Error Establishing a Database Connection”, Diagnosed Properly
Generated schematic — INFRA series, REV 2.6.

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 wrongDriver resultWhat 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

  1. Run the probe from the shell. One line, one error code, under a second.
  2. 1045: open wp-config.php and compare the user and password against the database's actual grants. Remember that localhost and 127.0.0.1 are different users in MySQL's grant tables, and so is the hostname.
  3. 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.
  4. 2002 or 2003: stop looking at WordPress. Ping the database host, check the port with nc -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.
  5. Connection fine but saves fail: check disk and the MySQL error log before anything else.
  6. 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

  1. 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.
  2. Make sure WP_DEBUG_LOG is on for a staging copy of production, so you can see the driver error without touching the live site's constants.
  3. 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.
  4. 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

Related in Hosting & Infrastructure