11 min read

Statement Timeouts: Why MySQL Spares Your Writes and PostgreSQL Cancels Everything

A runaway UPDATE joined wrong scanned for three hours on MySQL because max_execution_time only applies to SELECT, while on PostgreSQL the same class of bug dies at statement_timeout. But PostgreSQL's timeout also kills your maintenance VACUUM if you set it globally. Both defaults will surprise you.

The UPDATE had a join condition typo, the kind where t1.id = t1.id looks right at 2 a.m. and scans a cartesian product at any hour. On MySQL 8.0 it ran for three hours and eleven minutes on the primary before someone noticed the replica lag alert and killed the thread by hand. The team had a statement timeout configured, or thought they did: max_execution_time was set to 30 seconds globally. It never fired, because max_execution_time applies to SELECT statements and nothing else. A runaway write on MySQL has no server-side time limit at all.

Months later, on PostgreSQL, the same team set statement_timeout to 30 seconds globally and congratulated themselves, until the weekend maintenance script started failing: the manual VACUUM of a 400GB table kept dying at exactly 30 seconds with canceling statement due to statement timeout, because on PostgreSQL the timeout applies to every statement, including the maintenance you run from the same role as everything else. Two engines, two timeout philosophies, and both defaults are booby-trapped in opposite directions.

What does max_execution_time actually cover on MySQL?

It covers read-only SELECT statements, measured in milliseconds, and nothing else: not writes, not DDL, and not statements executing inside stored programs. The variable arrived in MySQL 5.7.8, and the per-statement optimizer hint form is the surgical version of the same mechanism:

-- global default for SELECTs, in milliseconds
SET GLOBAL max_execution_time = 30000;

-- per-statement override via optimizer hint
SELECT /*+ MAX_EXECUTION_TIME(5000) */ *
FROM events
WHERE created_at > now() - interval 1 day;

When it fires, the client gets ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded. The scoping gaps are the production story. An INSERT ... SELECT honors the timeout only for the SELECT portion's execution in some versions and paths, which is not a contract to build on; a plain UPDATE or DELETE has no timeout whatsoever; and a stored procedure's internal statements are exempt entirely, so a runaway inside a procedure runs to completion or until killed. That is why the three-hour UPDATE needed a human with KILL, and why MySQL shops that need write timeouts end up with external watchdogs, typically a scheduled process that inspects the processlist and kills statements matching age and user rules, which is a solution with its own failure modes around killing the wrong thing during maintenance windows.

What does PostgreSQL statement_timeout cover, and why does it kill maintenance?

It covers every statement the session runs, SELECT, INSERT, UPDATE, DDL, and VACUUM, measured in milliseconds from when the statement starts, and it applies per statement, not per transaction. Set it globally and your nightly VACUUM dies with the same error your runaway query would have: canceling statement due to statement timeout. This is the trap in my second story, and the fix is PostgreSQL's layered GUC system: set the timeout where the workload lives, not where the server defaults live.

-- tight limit for the application role only
ALTER ROLE app_rw SET statement_timeout = '30s';

-- generous allowance for the maintenance role
ALTER ROLE maintenance SET statement_timeout = '2h';

-- one-off escape hatch inside a maintenance session
SET statement_timeout = 0;
VACUUM (ANALYZE) events;

Two subtleties earn their keep. First, statement_timeout set in postgresql.conf applies to every role including superusers and background maintenance you run interactively, which is precisely the footgun; prefer role- and database-level settings, and treat the global value as a backstop set generously or not at all. Second, the timeout interacts with pooled connections: under transaction-mode pooling, a SET statement_timeout issued by one client can leak to the next borrower of the same server connection, so enforce it via ALTER ROLE rather than session SETs when a pooler is in front. The PgBouncer transaction-mode article catalogs that class of leak in full.

What about lock waits and idle sessions on each engine?

Statement timeouts do not help when the problem is waiting, and each engine has a separate, differently-shaped answer. On PostgreSQL, lock_timeout bounds how long a statement may wait to acquire a lock, which is the setting that keeps a DDL migration from queueing behind a long transaction forever; idle_in_transaction_session_timeout kills sessions that sit idle inside an open transaction, the classic connection-leak failure that blocks vacuum and holds locks; and idle_session_timeout, added in PostgreSQL 14, kills sessions idle outside any transaction. The idle-in-transaction one is the workhorse, and its interaction with long transactions is covered in the idle-in-transaction piece.

On MySQL, the equivalents are narrower. innodb_lock_wait_timeout, default 50 seconds, bounds InnoDB row-lock waits and ends the wait with ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction, but it says nothing about overall statement duration. lock_wait_timeout bounds metadata lock waits and defaults to 31,536,000 seconds, one year, which in practice means MDL waits are effectively unbounded unless you set it, and an ALTER waiting on a metadata lock will happily wait for days behind a forgotten transaction; the metadata locks notes show how to see that queue. wait_timeout and interactive_timeout, both default 28,800 seconds, kill idle connections, and the KILL command or sys schema helpers remain the manual override for everything else.

How do you roll timeouts out without paging yourself?

Start from measurement, not from a number that sounds good. Pull the p99 execution time per statement family from performance_schema digests or pg_stat_statements, set the timeout an order of magnitude above the slowest legitimate family, and exempt the roles that run legitimately long work: reporting, ETL, maintenance. On PostgreSQL, stage it role by role with log_min_error_statement or your log pipeline watching for the timeout error text, because every statement timeout firing is a client-visible error and some application code treats it as fatal rather than retryable. On MySQL, accept that SELECTs are all you get server-side, apply MAX_EXECUTION_TIME hints to the known-risky reporting queries, and build the watchdog for writes with an explicit allow-list of users and hours it must never kill, because the night your watchdog kills the online schema change you launched is worse than the night the runaway ran.

One discipline transfers across both engines: a timeout is a backstop, not a plan. The three-hour UPDATE would still have cost thirty seconds of pile-up on PostgreSQL before the timeout fired, and on a hot table thirty seconds of queued writes is itself an incident. The timeout caps the blast radius; the monitoring that notices a statement family getting slower is what keeps you from needing the cap.

Where does MonPG fit when timeouts start firing?

A statement timeout that fires is a symptom, and the question is always the same: is this a new bad query, or an old query meeting new data? MonPG's PostgreSQL monitoring keeps pg_stat_statements history per normalized query alongside lock and wait evidence, so a statement_timeout firing at 30 seconds comes with the answer attached: the plan changed Tuesday, or the table doubled, or the lock queue behind an idle transaction finally hit the limit, and you are fixing the cause instead of tuning the cap.

MonPG monitors PostgreSQL today; MySQL support is on the roadmap. When it lands, the MySQL side of this article is what it will surface: processlist age distributions so runaway writes are visible before the replica lag alert, digest history for the SELECT families that do have timeouts, and the lock-wait evidence behind ERROR 1205. Until then, the MySQL monitoring page tracks that work, and the portable rule stands: know exactly which statements your timeout covers, because on MySQL the answer is "only the reads" and on PostgreSQL it is "everything, including your maintenance," and both surprises arrive at the worst possible time.