MySQL Locking
Locking is the mechanism MySQL uses to manage concurrent access to the same data. Without locks, two transactions could simultaneously modify the same row, producing corrupted data. Understanding MySQL's locking system is essential for building high-concurrency applications and diagnosing performance bottlenecks.
Shared vs Exclusive Locks
Lock Type | Symbol | Who can hold it | Blocks |
|---|---|---|---|
Shared (S) | Read lock | Multiple transactions simultaneously | Exclusive locks only — other readers can proceed |
Exclusive (X) | Write lock | Only one transaction at a time | All other shared and exclusive locks |
-- Acquire a shared lock: other sessions can read but not write SELECT * FROM orders WHERE order_id = 100 LOCK IN SHARE MODE; -- Acquire an exclusive lock: other sessions cannot read or write SELECT * FROM orders WHERE order_id = 100 FOR UPDATE; -- MySQL 8.0+ syntax (preferred) SELECT * FROM orders WHERE order_id = 100 FOR SHARE; SELECT * FROM orders WHERE order_id = 100 FOR UPDATE;
Row-Level vs Table-Level Locking
Lock Granularity | Storage Engine | Characteristic |
|---|---|---|
Row-level | InnoDB | Locks only the specific rows touched — best concurrency |
Table-level | MyISAM, MEMORY | Locks the entire table — simple but blocks all other writers |
Page-level | BerkeleyDB (legacy) | Rarely used in modern MySQL |
InnoDB's row-level locking is one of its biggest advantages over MyISAM. Two transactions can modify different rows in the same table simultaneously.
InnoDB Row Locks — Three Types
Lock Type | Description | Situation |
|---|---|---|
Record lock | Locks a single index record (a specific row) | UPDATE/DELETE by primary key or unique index |
Gap lock | Locks the gap between two index values — prevents inserts into the range | Range queries in REPEATABLE READ |
Next-key lock | Record lock + gap lock before the record — default InnoDB lock | Most range queries and index scans |
-- Record lock: locks only the row where order_id = 100 SELECT * FROM orders WHERE order_id = 100 FOR UPDATE; -- Next-key lock: locks the record AND the gap before it -- Prevents another transaction from inserting order_id between 90 and 100 SELECT * FROM orders WHERE order_id > 90 AND order_id <= 100 FOR UPDATE; -- Gap lock: if no row exists at that value, only the gap is locked SELECT * FROM orders WHERE order_id = 95 FOR UPDATE; -- If order_id 95 doesn't exist, locks the gap (90, 100) to prevent insert
Intention Locks
Before acquiring a row lock, InnoDB places an intention lock on the table. Intention locks allow table-level operations (like LOCK TABLES) to detect that rows are already locked:
Intention Lock | Meaning |
|---|---|
IS (Intention Shared) | Transaction intends to acquire shared locks on rows in this table |
IX (Intention Exclusive) | Transaction intends to acquire exclusive locks on rows in this table |
Intention locks are table-level, lightweight, and never block each other. They only conflict with full table locks (LOCK TABLES).
LOCK TABLES and UNLOCK TABLES
-- Lock a table for reading (shared lock) LOCK TABLES orders READ; SELECT * FROM orders; -- Allowed -- INSERT INTO orders ... -- Blocked for all sessions including this one UNLOCK TABLES; -- Lock a table for writing (exclusive lock) LOCK TABLES orders WRITE; SELECT * FROM orders; -- Allowed (this session only) UPDATE orders SET ...; -- Allowed (this session only) -- Other sessions: ALL reads and writes are blocked UNLOCK TABLES; -- Lock multiple tables (must lock all tables you'll use) LOCK TABLES orders WRITE, customers READ; -- ... operations ... UNLOCK TABLES;
SELECT FOR UPDATE — Pessimistic Locking
Use SELECT ... FOR UPDATE when you need to read a row and then update it, ensuring no other transaction modifies the row between your read and write:
-- Safely update account balance without a lost-update race START TRANSACTION; -- Acquire exclusive lock on this row SELECT balance FROM accounts WHERE account_id = 1 FOR UPDATE; -- Other transactions trying to UPDATE account_id=1 will WAIT here UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; COMMIT; -- Lock released
-- MySQL 8.0 SKIP LOCKED and NOWAIT options -- Process the next available job without waiting for locked rows SELECT * FROM job_queue WHERE status = 'pending' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED; -- Skip rows locked by other sessions -- Fail immediately instead of waiting SELECT * FROM accounts WHERE account_id = 1 FOR UPDATE NOWAIT; -- Raises error if row is locked
Deadlocks
A deadlock occurs when two (or more) transactions each hold a lock the other needs, creating a circular wait. MySQL's InnoDB detects deadlocks automatically and rolls back the transaction with the smallest undo log (the "lighter" transaction):
-- Classic deadlock scenario -- Session A: START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; -- locks row 1 -- (pauses) -- Session B: START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE account_id = 2; -- locks row 2 UPDATE accounts SET balance = balance + 100 WHERE account_id = 1; -- WAITS for Session A -- Session A continues: UPDATE accounts SET balance = balance + 100 WHERE account_id = 2; -- WAITS for Session B -- InnoDB detects the deadlock! -- One session gets: ERROR 1213: Deadlock found when trying to get lock
Deadlock prevention strategies:
Always access tables and rows in the same order across all transactions
Keep transactions short — fewer locks held for less time means fewer conflicts
Use SELECT ... FOR UPDATE to pre-acquire all needed locks at transaction start
Retry transactions that fail with error 1213 (deadlock) — this is expected and correct behavior
On INSERT-heavy tables, avoid hot-spot rows that many transactions compete for
Viewing Lock Information
-- Show all currently waiting lock requests (MySQL 8.0+)
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.INNODB_TRX r
JOIN information_schema.INNODB_TRX b
ON r.trx_wait_started IS NOT NULL
AND b.trx_id = (
SELECT blocking_trx_id
FROM performance_schema.data_lock_waits
WHERE requesting_engine_transaction_id = r.trx_id
LIMIT 1
);
-- Show all current data locks
SELECT * FROM performance_schema.data_locksG
-- Show lock waits
SELECT * FROM performance_schema.data_lock_waitsGSHOW ENGINE INNODB STATUS
SHOW ENGINE INNODB STATUSG -- Key sections to look for in the output: -- TRANSACTIONS — active transactions and their state -- LATEST DETECTED DEADLOCK — details of the most recent deadlock -- BUFFER POOL AND MEMORY — buffer pool usage -- ROW OPERATIONS — rows read, inserted, updated, deleted per second
Lock Wait Timeout
-- How long a transaction will wait for a lock before giving up SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- Default: 50 seconds -- Change for the current session SET SESSION innodb_lock_wait_timeout = 5; -- When a lock wait times out, the current statement is rolled back -- (not necessarily the entire transaction) -- ERROR 1205: Lock wait timeout exceeded; try restarting transaction
Metadata Locks (MDL)
MySQL also uses Metadata Locks (MDL) to protect schema changes. When a transaction reads or writes a table, MySQL acquires a shared MDL on it. DDL operations (ALTER TABLE, DROP TABLE) need an exclusive MDL and must wait for all current transactions on that table to finish:
-- Find transactions holding metadata locks SELECT p.id, p.user, p.host, p.db, p.command, p.time, p.info FROM information_schema.PROCESSLIST p WHERE p.command != 'Sleep' ORDER BY p.time DESC; -- Show metadata lock holders (MySQL 5.7.3+) SELECT * FROM performance_schema.metadata_locks WHERE OBJECT_TYPE = 'TABLE'G
Practical: Safe Inventory Deduction
-- Prevent overselling: check and deduct stock atomically START TRANSACTION; -- Lock the product row before checking stock SELECT stock_qty FROM products WHERE product_id = 7 FOR UPDATE; -- Check stock (now safe from concurrent modifications) -- Application checks: if stock_qty >= requested_qty, proceed UPDATE products SET stock_qty = stock_qty - 2 WHERE product_id = 7 AND stock_qty >= 2; -- ROW_COUNT() = 0 means stock was insufficient -- Application checks ROW_COUNT() and rolls back if 0 COMMIT;
Optimistic Locking Pattern
Optimistic locking avoids holding database locks by checking for concurrent changes at write time using a version column:
-- Add a version column to the table ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1; -- Application reads the row (no lock) SELECT product_id, price, stock_qty, version FROM products WHERE product_id = 7; -- Returns: product_id=7, price=29.99, stock_qty=50, version=3 -- Application updates, including the version check UPDATE products SET stock_qty = stock_qty - 2, version = version + 1 WHERE product_id = 7 AND version = 3; -- fails if version changed since our read -- Check if another transaction snuck in -- ROW_COUNT() = 0 means a concurrent update happened; retry or report conflict SELECT ROW_COUNT(); -- 0 = conflict, 1 = success
Deadlock Retry Pattern in Application Code
# Python pattern for deadlock retry: # MAX_RETRIES = 3 # for attempt in range(MAX_RETRIES): # try: # conn.start_transaction() # # ... DML statements ... # conn.commit() # break # success # except mysql.connector.errors.DatabaseError as e: # conn.rollback() # if e.errno == 1213 and attempt < MAX_RETRIES - 1: # time.sleep(0.1 * (attempt + 1)) # exponential backoff # continue # raise # re-raise after max retries
Best Practices
Access tables in the same alphabetical or dependency order in all transactions to prevent deadlocks
Keep transactions short — commit immediately after the last DML statement
Use SELECT ... FOR UPDATE only when you intend to update the row in the same transaction
Handle error 1213 (deadlock) with an automatic retry with exponential backoff in application code
Avoid DDL during peak traffic — ALTER TABLE on large tables blocks all concurrent transactions
Monitor performance_schema.data_lock_waits to identify locking hotspots before they become production incidents
Reduce innodb_lock_wait_timeout to fail fast in latency-sensitive services rather than queuing for 50 seconds
Use optimistic locking (version column) for low-contention scenarios to avoid pessimistic lock overhead