BlueByte
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

By Haneul SeoUpdated September 24, 20267 min

Hi, it's BlueByte. An UPDATE that normally returns in milliseconds sits there for most of a minute and then fails with ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction. Nothing is broken here — another transaction is holding the row lock your statement needs, and InnoDB waited exactly as long as it was configured to wait before giving up. We'll walk through the message and the shapes it arrives in, why that lock is still held, how to name the blocking session before it slips away, the fix per cause, and what was actually rolled back before you retry.

What 1205 reports, and how drivers reshape it

In the client it is one line:

mysql> UPDATE orders SET status = 'paid' WHERE id = 4711;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

The MySQL error reference lists it as error number 1205, symbol ER_LOCK_WAIT_TIMEOUT, SQLSTATE HY000, message Lock wait timeout exceeded; try restarting transaction. Drivers wrap that text rather than replace it, so the same failure reaches you as SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded from PDO, or buried in a connection-pool stack trace from an ORM. Two things survive every wrapper: the number 1205 and the SQLSTATE HY000. A deadlock carries a different pair — 1213 with SQLSTATE 40001 — and the two want different responses, which is where most of the confusion in this area starts.

Why the row is still locked when your statement gives up

InnoDB takes row locks as a transaction touches rows and releases them at COMMIT or ROLLBACK. Your wait expired because some other transaction reached neither:

  • A session left a transaction open and idle — an explicit START TRANSACTION, or autocommit switched off by a client or framework, and then a screen, a network hop, or a person who stepped away.
  • A long statement holds its locks for its entire run: a multi-million-row UPDATE, a wide SELECT ... FOR UPDATE, an import wrapped in a single transaction.
  • An UPDATE ... WHERE or SELECT ... FOR UPDATE without a usable index locks the rows it scanned rather than only the rows it matched, so a small-looking statement pins a large range.
  • Two jobs touch the same rows in a different order and one waits behind the other for longer than the timeout allows.

The clock is innodb_lock_wait_timeout, in seconds. The manual's property table gives Default Value 50, Minimum Value 1, Maximum Value 1073741824, Scope Both, Dynamic Yes. Fifty seconds of waiting is the default behaviour, not a hang.

Name the blocking session with sys.innodb_lock_waits

Ask while the wait is still on — the row exists only while someone is waiting:

SELECT waiting_pid, waiting_query, wait_age, blocking_pid,
       blocking_query, sql_kill_blocking_connection
FROM sys.innodb_lock_waits\G
*************************** 1. row ***************************
                 waiting_pid: 812
               waiting_query: UPDATE orders SET status = 'paid' WHERE id = 4711
                    wait_age: 00:00:41
                blocking_pid: 774
              blocking_query: NULL
sql_kill_blocking_connection: KILL 774

A blocking_query of NULL throws people off, and it is documented behaviour: the column returns NULL if the blocking session becomes idle. That is the answer, not a dead end — the blocker is not running a statement, it is sitting on an open transaction. The view also hands you two prepared statements: sql_kill_blocking_query kills the blocking statement, sql_kill_blocking_connection kills the session running it.

Cross-check INNODB_TRX before you kill anything

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_rows_locked, trx_query
FROM information_schema.innodb_trx ORDER BY trx_started;

trx_state reads LOCK WAIT for a transaction waiting on a lock and RUNNING for one that is not. Read it together with trx_started: a blocker that began twenty minutes ago with an empty trx_query is the idle-transaction case, while one that started seconds ago with a large trx_rows_locked is the batch case. For lock-by-lock detail, performance_schema.data_locks holds the pending locks queued for a row or table, and performance_schema.data_lock_waits shows which held lock blocks which request.

Fix it by cause, cheapest move first

Idle blocker: have that session COMMIT or ROLLBACK, or run the KILL the view generated. Killing the connection rolls its transaction back, which is harmless on an idle session and expensive on a half-finished batch, because that rollback has to undo everything it did.

KILL 774;
Query OK, 0 rows affected (0.00 sec)

Unindexed predicate: the manual's own advice for lock contention is to create indexes on the columns used in SELECT ... FOR UPDATE and UPDATE ... WHERE statements. Check the plan first, then add the index.

EXPLAIN UPDATE orders SET status = 'paid' WHERE customer_ref = 'C-91823';
ALTER TABLE orders ADD INDEX idx_orders_customer_ref (customer_ref);

Long transaction: keep transactions that insert or update data small enough that they do not stay open for long periods, and commit batches in chunks instead of in one span. If you genuinely need a longer wait for one job, raise the timeout for that session only and leave the server default alone:

SET SESSION innodb_lock_wait_timeout = 120;

Read this before you write the retry loop

The manual is precise about scope: a lock wait timeout causes InnoDB to roll back the current statement, the one that was waiting for the lock and encountered the timeout. To have the entire transaction roll back, the server must be started with --innodb-rollback-on-timeout enabled. Retry the statement under the default behaviour, or the entire transaction when that option is on.

So by default your transaction is still open after 1205, still holding every lock it took earlier. A retry loop that re-runs only the failed statement inside that same transaction keeps those locks and often times out again. Check which behaviour you are on:

SELECT @@innodb_lock_wait_timeout, @@innodb_rollback_on_timeout;
+----------------------------+------------------------------+
| @@innodb_lock_wait_timeout | @@innodb_rollback_on_timeout |
+----------------------------+------------------------------+
|                         50 |                            0 |
+----------------------------+------------------------------+

innodb_rollback_on_timeout is global and not dynamic, so changing it means a restart, not a SET GLOBAL.

A worked example: reconciliation against checkout

A nightly reconciliation job starts at 01:00 and updates orders by customer_ref, a column with no index. Checkout writes start failing at 01:02 with 1205. sys.innodb_lock_waits shows waiting_pid: 812 with wait_age: 00:00:41, blocking_pid: 774, and a NULL blocking_query. information_schema.innodb_trx shows session 774 in RUNNING state, started at 01:00:07, with trx_rows_locked in the hundreds of thousands — the reconciliation scanned the whole table and locked what it scanned. The on-call engineer does not kill it, because the rollback would take as long as the run; they let it finish, checkout recovers at 01:09, and the next morning the fix goes in: an index on customer_ref and commits every 5,000 rows. The following night trx_rows_locked peaks in the hundreds and checkout never notices the job.

Confirm it is gone, and keep it gone

Re-run the statement that failed; it should return immediately. Then check that nobody is queued behind anything:

SELECT COUNT(*) AS waiters FROM sys.innodb_lock_waits;
SELECT trx_mysql_thread_id, trx_state, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s
FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

An empty first result and no rows in the second is what healthy looks like. Keep it that way by alerting on that second query — transactions older than a minute are the raw material of every 1205 — and by taking rows in the same order in every job that touches the same tables.

How this differs from 1213 and from a metadata lock wait

A deadlock is a cycle, not a queue: two transactions each hold what the other needs. InnoDB detects it, picks a victim, and rolls that transaction back whole, reporting 1213 with SQLSTATE 40001. Retry the entire transaction there. One wrinkle connects them: if deadlock detection is turned off with innodb_deadlock_detect, InnoDB falls back to innodb_lock_wait_timeout to roll transactions back when a deadlock occurs, so genuine deadlocks then surface as 1205. A third case looks similar and is not InnoDB row locking at all: DDL waiting behind an open transaction is waiting on a metadata lock, which MySQL exposes through performance_schema.metadata_locks rather than the InnoDB lock tables.

Next time a write dies at 1205, walk these checks back in order: which error number and SQLSTATE you actually got, who sys.innodb_lock_waits names as the blocker, whether that blocker is idle or busy in innodb_trx, and only then decide between waiting, killing, and indexing.

Related questions

Should my application retry automatically on 1205?

Yes, but retry the right scope. Under the default innodb_rollback_on_timeout=OFF only the timed-out statement was rolled back, so the transaction is still open and still holds its earlier locks; the safe pattern is to roll back the transaction yourself, then replay it from the start with a short backoff. Deadlocks (1213) are different: InnoDB already rolled the whole transaction back, so you replay it directly.

blocking_query is NULL. What am I supposed to kill?

The session, not the statement. sys.innodb_lock_waits returns NULL for blocking_query when the blocking session becomes idle, which means it is holding an open transaction and running nothing. Use blocking_pid, or the ready-made statement in sql_kill_blocking_connection, and then fix the client that leaves transactions open.

Is it safe to just raise innodb_lock_wait_timeout?

It is a deliberate trade, not a fix. The variable is Scope Both and Dynamic Yes, so you can raise it for one session that legitimately needs a longer wait. Raising it globally makes every waiter hold its own locks longer, which spreads the queue instead of shortening it. Fix the blocker or the index first, and use a higher timeout only where a slow job is expected.

Why did I get a lock wait timeout instead of a deadlock?

Because there was no cycle. A deadlock needs two transactions each waiting on what the other holds, and InnoDB rolls one of them back as soon as it detects that. A one-way wait has no cycle to detect, so the waiter simply waits out innodb_lock_wait_timeout and reports 1205. If deadlock detection has been disabled with innodb_deadlock_detect, real deadlocks also come out as 1205.

sys.innodb_lock_waits returns nothing, but the application keeps reporting 1205.

You are almost certainly querying between waits. The view only has rows while a transaction is actively waiting, and a 1205 that has already been raised is over. Catch it in the act: poll the view every second during the failing window, or query information_schema.innodb_trx for trx_state = 'LOCK WAIT' at the same moment your application logs the error.

References

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

More in this category

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Since the CVE-2022-24765 fix in Git 2.35.2, Git refuses to read a repository whose working tree or .git directory is owned by a different user than the one running the command. It shows up in containers, CI jobs, sudo sessions and shared drives. Fix the ownership if the repo should be yours, or add the exact path to safe.directory in your global config — never in the repo's own config, which Git ignores for this.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.

Kubernetes
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)

The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang

The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.

Docker
curl: (60) SSL certificate problem: unable to get local issuer certificateFixed

curl: (60) SSL certificate problem: unable to get local issuer certificate

curl walked the certificate chain the server sent and reached a certificate whose issuer is not in the CA store it is reading, so it refused the connection with exit code 60. The issuer is missing for one of four reasons: the server sends only its leaf certificate without the intermediate (browsers hide this by fetching it themselves), a TLS-inspecting proxy re-signed the site with a company CA that the container or runner does not trust, curl is reading a different CA bundle than you think (CURL_CA_BUNDLE, SSL_CERT_FILE, a vendored curl), or the ca-certificates package is too old. curl -v shows which store was used and openssl s_client shows what the server sent; fix the side that is missing the link and confirm with -w '%{ssl_verify_result}'.

curl