MySQL Server Has Gone Away: Why Error 2006 Is a Symptom, Not a Cause
Published 6 September 2026. Every figure below was measured on one live server we operate — a 2 vCPU, 7.8 GiB KVM VPS running MySQL 8.0.46 and PHP 8.3.6 on Ubuntu 24.04 — by deliberately breaking connections and recording what came back. Nothing here is quoted from documentation.
1. Error 2006 Is a Symptom Shared by at Least Three Different Faults
This is why the error is so frustrating to fix, and why most advice about it misses. MySQL server has gone away is not a diagnosis. It is what the client driver reports whenever it discovers the connection is no longer usable, regardless of why. We reproduced three unrelated faults on the same machine and every one of them surfaced as the same message:
| What actually went wrong | Error PHP reports | How long the call took |
|---|---|---|
Connection idle past wait_timeout | 2006 | 0.1 ms |
Packet larger than max_allowed_packet | 1153, then 2006 | 299 ms |
| Connection killed mid-query | 2006 | 2,014 ms |
Two things in that table do the diagnostic work, and neither is the error text.
The duration tells you which fault you have. A 2006 that returns in a fraction of a millisecond means the socket was already dead before your query left PHP — nobody was waiting, the client simply found a closed pipe. A 2006 that takes seconds means the connection was alive when the query started and died during it. Those are opposite problems with opposite fixes, and the message is identical.
The error before the error is the real one. In the packet case, MySQL returned 1153 first. The 2006 was the next query failing on a connection that 1153 had already destroyed. If your framework logs only the last exception, or your code retries on failure, you see the echo and never the cause.
2. Fault One: The Connection Died While PHP Was Busy Elsewhere
MySQL closes any connection that sits idle longer than wait_timeout. The default on this server is 28,800 seconds — eight hours — so this fault is rare in web requests and common in long-running scripts: CSV imports, migrations, queue workers, report generators. Anything that opens a connection, then spends minutes doing work in PHP before touching the database again.
To measure the boundary precisely we set wait_timeout to 3 seconds on a single session, which affects nothing else on the server, then queried after increasing idle periods:
// diagnose-idle.php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$m = new mysqli('localhost', 'user', 'pass', 'db');
$m->query("SET SESSION wait_timeout = 3");
foreach ([2, 3, 4] as $pause) {
sleep($pause);
$t0 = microtime(true);
try {
$m->query("SELECT 1");
printf("idle %ds -> OK (%.1f ms)\n", $pause, (microtime(true) - $t0) * 1000);
} catch (mysqli_sql_exception $e) {
printf("idle %ds -> FAIL (%.1f ms) errno %d: %s\n",
$pause, (microtime(true) - $t0) * 1000, $e->getCode(), $e->getMessage());
break;
}
$m = new mysqli('localhost', 'user', 'pass', 'db');
$m->query("SET SESSION wait_timeout = 3");
}The result on our server:
idle 2s -> OK (0.3 ms)
idle 3s -> OK (3.0 ms)
idle 4s -> FAIL (0.1 ms) errno 2006: MySQL server has gone awayNote the 0.1 ms. The failure is faster than the successes, because no network round trip happens at all. That timing signature is the cheapest way to identify this fault in production logs — if you record query durations, an instant 2006 is this one, every time.
The reconnect setting you will find in older answers no longer exists
A great deal of published advice tells you to enable mysqli.reconnect. On PHP 8.3.6 we checked whether the directive is still readable at all:
php -r "var_dump(ini_get('mysqli.reconnect'));"
// bool(false) — the directive has been removed, not merely disabledIt was deprecated in PHP 8.2 and removed thereafter. Automatic reconnection was always a poor mechanism regardless: a silently reopened connection loses every session variable, every temporary table, and any open transaction — so a partially applied write could be committed as if nothing had happened. Losing the connection loudly is safer than resuming it quietly.
The pattern that works is explicit, and it is six lines:
function queryWithRetry(mysqli &$m, string $sql, callable $connect, int $tries = 1) {
while (true) {
try {
return $m->query($sql);
} catch (mysqli_sql_exception $e) {
// 2006 = gone away, 2013 = lost during query. Both mean: reopen.
if (!in_array($e->getCode(), [2006, 2013], true) || $tries-- < 1) {
throw $e;
}
$m = $connect(); // fresh connection, session state deliberately reset
}
}
} only retry statements that are safe to run twice. A SELECT is. An INSERT outside a transaction is not — the first attempt may have committed before the connection dropped, and you will write the row twice. If you cannot prove idempotence, let the error propagate and fail the job.
3. Fault Two: The Payload Was Bigger Than the Server Would Accept
This is the fault that produces 2006 in CSV importers, image uploads stored as BLOBs, and bulk INSERT statements built by concatenating thousands of rows. MySQL rejects any single packet larger than max_allowed_packet and then closes the connection. Our server's limit is the 64 MB default for MySQL 8:
We inserted a LONGBLOB at 90% of that limit, then at 105%, and recorded both the error and the state of the connection afterwards:
| Payload | Share of limit | Result | Connection afterwards |
|---|---|---|---|
| 57.6 MB | 90% | Inserted | Usable |
| 67.2 MB | 105% | errno 1153 in 299 ms | Dead — next query returns 2006 |
The exact message on the failing insert was Got a packet bigger than 'max_allowed_packet' bytes. That is the sentence to search your logs for. If you find it anywhere near a 2006, you have this fault and no other.
Why raising the limit is the wrong first move
Almost every answer to this error says to set max_allowed_packet to 512 MB or 1 GB. It does stop the error. It also means that any single statement — including one produced by a bug, or by a user uploading a file far larger than you intended — can ask the server to allocate that much memory for one client. On a 7.8 GiB machine already running five sites, a handful of concurrent 1 GB packets is an out-of-memory kill, which takes down every database on the box rather than one query.
The limit is doing its job. The statement is the problem.
The workaround that looks obvious and does not work
The natural instinct is to keep the single row and append to it in small pieces, so that no individual packet is oversized. We tried exactly that — 4 MB at a time into one LONGBLOB:
$st = $m->prepare("UPDATE blobs SET payload = CONCAT(payload, ?) WHERE id = ?");
// ...4 MB per call, 17 callsIt fails, and the error explains why:
mysqli_sql_exception: Result of concat() was larger than max_allowed_packet (67108864) - truncatedmax_allowed_packet bounds the result of an expression, not just the incoming packet. Every chunk you send is small, but the value the server is asked to build is not. Worse, the word in that message is truncated — this failure mode can silently corrupt data rather than refusing it. We found this while writing the article, having assumed chunked CONCAT would work.
What works, and what it costs
Store the payload across several rows and reassemble it in the application. The same 67.2 MB that killed the connection as one statement went in as 17 rows of 4 MB, byte-exact, with the connection still usable at the end.
CREATE TABLE chunks (
object_id INT,
seq INT,
data LONGBLOB,
PRIMARY KEY (object_id, seq)
) ENGINE=InnoDB;$chunkSize = 4 * 1024 * 1024; // comfortably below any sane limit
$st = $m->prepare("INSERT INTO chunks (object_id, seq, data) VALUES (?, ?, ?)");
$st->bind_param('iis', $objectId, $seq, $piece);
for ($seq = 0, $offset = 0; $offset < strlen($payload); $seq++) {
$piece = substr($payload, $offset, $chunkSize);
$st->execute();
$offset += strlen($piece);
}To find out whether that costs anything, we ran both approaches five times each at 57.6 MB — a size that succeeds either way — alternating between them so that cache state could not favour one:
| Approach | Median | Range across 5 runs |
|---|---|---|
| One 57.6 MB packet | 4,030 ms | 2,853 – 4,807 ms |
| 15 packets of 4 MB | 2,831 ms | 2,372 – 3,155 ms |
Chunking is about 1.4× faster on the median, and its worst run is faster than the single-packet median. The spread matters more than the average here: the single-packet approach varied by nearly two seconds between identical runs, because a 57.6 MB write forces InnoDB to extend the tablespace and flush in ways a 4 MB write does not. Chunking is both quicker and far more predictable — and unlike the single statement, it has no ceiling.
a note on how those numbers were produced, because we nearly published a wrong one. Our first single-packet measurement was 17,829 ms, which would have made chunking look 14× faster. That run was the first write into a freshly created table on a cold buffer pool, and it was not representative of anything. A second run gave 2,464 ms and would have made chunking look slower. Only repeating both approaches five times gave a figure worth printing. One measurement of a storage operation is not a benchmark.
4. Fault Three: Something Killed the Connection While the Query Ran
A server restart, the kernel OOM killer choosing mysqld, or an administrator running KILL all produce the same client-side result. We armed a killer to fire two seconds into a six-second query, over both a unix socket and a TCP connection:
| Transport | Command | Result |
|---|---|---|
| Unix socket | KILL <id> | errno 2006 after 2,017 ms |
| TCP 127.0.0.1 | KILL <id> | errno 2006 after 2,014 ms |
| Either | KILL QUERY <id> | Query returns normally, connection survives |
Two conclusions. The transport makes no difference, so switching between localhost and 127.0.0.1 — a suggestion that appears in a lot of threads — changes nothing about this error. And KILL QUERY is not this fault: it cancels the statement without dropping the connection, so it never produces 2006. If you are killing runaway queries in production, KILL QUERY is the one that does not break the calling application.
We did not restart the production MySQL instance to demonstrate this, because five live sites depend on it. KILL produces the identical client-side signature, which is what the article is about.
5. Telling Them Apart in Sixty Seconds
Run these three checks in order. The first one that answers stops the search.
Check the application log for the error immediately before the 2006. If it is 1153, or contains the phrase Got a packet bigger than, you have fault two and nothing else matters. This is the check people skip, and it is the one that resolves the majority of cases involving imports or uploads.
Check how long the failing call took. Under a millisecond means the connection was already closed: fault one, an idle timeout. Hundreds of milliseconds or more means it died in flight: fault two or three.
Ask the server what it saw. MySQL counts connections it closed itself, which is exactly what an idle timeout is:
SHOW GLOBAL STATUS WHERE Variable_name IN ('Aborted_clients', 'Aborted_connects');
SHOW VARIABLES WHERE Variable_name IN
('wait_timeout', 'interactive_timeout', 'max_allowed_packet',
'net_read_timeout', 'net_write_timeout');A climbing Aborted_clients alongside your errors confirms fault one. If it is flat while errors continue, the connection is dying for a reason the server does not consider its own doing — look at fault two, then at the MySQL error log and dmesg for an OOM kill.
6. What to Actually Set
Configuration is the last step, not the first, and the values below are ceilings rather than targets. On a small VPS, in /etc/mysql/mysql.conf.d/mysqld.cnf:
[mysqld]
# Raise only if you have proven fault two AND cannot chunk the payload.
# 64M is the MySQL 8 default and is already generous for row data.
max_allowed_packet = 64M
# Long-running CLI jobs, not web requests, are what hit this.
# Prefer fixing the job to holding a connection open for hours.
wait_timeout = 600
interactive_timeout = 600Lowering wait_timeout from the eight-hour default is the change most small servers actually benefit from: abandoned connections are reclaimed sooner, which matters far more when max_connections is 151 and several applications share the instance. It makes fault one more likely to appear, which is the point — a batch job that holds an idle connection for twenty minutes has a bug worth surfacing.
Apply and verify without a restart where possible, since max_allowed_packet and the timeouts are dynamic:
sudo mysql -e "SET GLOBAL max_allowed_packet = 64*1024*1024;"
sudo mysql -e "SET GLOBAL wait_timeout = 600;"
sudo mysql -e "SHOW VARIABLES WHERE Variable_name IN ('max_allowed_packet','wait_timeout');" SET GLOBAL applies to connections opened afterwards. Existing connections keep the value they were created with, which is why the error often appears to persist for several minutes after a change that did work. Confirm from a new connection, not the one you are already in.
7. Frequently Asked Questions
Does increasing max_allowed_packet fix "MySQL server has gone away"?
Only if your fault is an oversized packet, which you confirm by finding error 1153 just before the 2006. If the cause is an idle timeout or a killed connection, raising the packet limit changes nothing and adds memory risk. On a small server, a high limit lets one client request enormous allocations, which can trigger an out-of-memory kill affecting every database on the machine.
What is the difference between MySQL error 2006 and 2013?
Error 2006 means the client found the connection unusable; 2013 means it was lost specifically during an active query. In our tests every fault reported 2006, including a connection killed mid-query, so 2013 is less common than its documentation suggests. Treat both identically: reopen the connection, and diagnose from the preceding error and the call duration.
Why does the error appear only during large CSV imports?
Imports hit two faults at once. Rows batched into one giant INSERT can exceed max_allowed_packet, producing 1153 followed by 2006. Imports also spend long stretches parsing in PHP without querying, so the connection can pass wait_timeout while idle. Check the call duration: an instant failure is the timeout, a slow one is the packet.
Should I use mysqli ping or reconnect to keep the connection alive?
No. The mysqli.reconnect directive was removed in PHP 8.2 and returns false on PHP 8.3. Automatic reconnection silently discards session variables, temporary tables and open transactions, which can commit a half-finished write. Catch errno 2006, open a new connection explicitly, and retry only statements that are safe to run twice.
Can I store files larger than max_allowed_packet in MySQL?
Not as a single value, and appending in chunks with CONCAT fails too because the limit also bounds expression results. Split the payload across rows keyed by sequence number and reassemble in the application. In our measurements that was also about 1.4 times faster than one large packet, with far less variation between runs.
8. The Short Version
- 2006 is a symptom with at least three causes. Do not fix it directly.
- Look for error 1153 immediately before it — that is the real fault in most import and upload cases.
- Time the failing call. Instant means an idle timeout; slow means the connection died in flight.
- Chunk large payloads across rows rather than raising the packet limit. It is faster, more predictable, and has no ceiling.
- Do not use automatic reconnection. Catch the error, reopen deliberately, retry only what is idempotent.
If the underlying problem is that your application outgrew its database server rather than its configuration, our capacity model measured across seven applications covers what one VPS actually sustains, and the Laravel production setup guide covers PHP-FPM and database tuning on the same hardware.