{"componentChunkName":"component---src-gatsby-theme-chronoblog-templates-note-js","path":"/notes/database-locking-for-update/","result":{"data":{"mdx":{"parent":{"__typename":"File","fields":{"gitLogLatestDate":"2026-08-11 23:51:57 +0200"}},"id":"5f745604-5cd8-566a-8dc0-0e4f3a4e07fb","excerpt":"Two concurrent transactions that each  SELECT  a row, change it in memory, then  UPDATE  can lose an update or claim the same resource twice…","frontmatter":{"title":"Database locking with raw SQL (FOR UPDATE)","date":"2026-08-11 21:38:00 UTC","job_ad":null,"job_ad_id":null,"job_ad_url":null,"tags":["postgres","sql","locking","transactions","node","pg"],"cover":{"childImageSharp":{"fluid":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAKCAYAAAC0VX7mAAAACXBIWXMAAAPoAAAD6AG1e1JrAAABRklEQVQoz42SS2/bMBCE/f//Tw+9pYeilx6C9FjAtRtbiSTSNimalPj6AlHyq62N8kCAi5nZ2R0umE/OebyxFl5fDZuNxdlSoqosq5VCiFCwWmfWa01V9QxDYc98WPwp2LtRsKPaWoYecoL63fJrKZGysDFdZr1SvFUO7x8InoopZpwdOBz2NE2Lc6k0um6cwoSbxPhb8AKGGDNaGaSUiPad7e+vNNtveLc8Y0NIxJBumtx1OApO3csL736ixDO79gdKKbTu8D6XdZy5M/quw3gWnM4YkOsj1vao3QvN5jNKfCEnVcQejjy667RBSMFOjmG8YY0Chhl4xB1retuQs/8/waEP+BDwg+HQPtFsPuH0d2KMMwZSuuU9Hjlcj5ww+ojWB+q6Zr/flV8whZJv9v9vh+m0wwuw0xYh5JS8aDHGzqHkG4cfg4QO37pExYkAAAAASUVORK5CYII=","aspectRatio":2.0869565217391304,"src":"/static/5f8c16126be516dca4d301f1deed1cb1/c4ecb/cover.png","srcSet":"/static/5f8c16126be516dca4d301f1deed1cb1/57ab0/cover.png 192w,\n/static/5f8c16126be516dca4d301f1deed1cb1/f4739/cover.png 384w,\n/static/5f8c16126be516dca4d301f1deed1cb1/c4ecb/cover.png 768w","srcWebp":"/static/5f8c16126be516dca4d301f1deed1cb1/dd090/cover.webp","srcSetWebp":"/static/5f8c16126be516dca4d301f1deed1cb1/ae504/cover.webp 192w,\n/static/5f8c16126be516dca4d301f1deed1cb1/fef30/cover.webp 384w,\n/static/5f8c16126be516dca4d301f1deed1cb1/dd090/cover.webp 768w","sizes":"(max-width: 768px) 100vw, 768px","presentationWidth":768,"presentationHeight":366},"resize":{"src":"/static/5f8c16126be516dca4d301f1deed1cb1/c4ecb/cover.png"}}}},"fields":{"slug":"/notes/database-locking-for-update/","readingTime":{"text":"5 min read"}},"body":"function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }\n\nfunction _objectWithoutProperties(source, excluded) { if (source == null) return {}; var target = _objectWithoutPropertiesLoose(source, excluded); var key, i; if (Object.getOwnPropertySymbols) { var sourceSymbolKeys = Object.getOwnPropertySymbols(source); for (i = 0; i < sourceSymbolKeys.length; i++) { key = sourceSymbolKeys[i]; if (excluded.indexOf(key) >= 0) continue; if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; target[key] = source[key]; } } return target; }\n\nfunction _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }\n\n/* @jsxRuntime classic */\n\n/* @jsx mdx */\nvar _frontmatter = {\n  \"title\": \"Database locking with raw SQL (FOR UPDATE)\",\n  \"date\": \"2026-08-11 21:38:00 UTC\",\n  \"cover\": \"./cover.png\",\n  \"tags\": [\"postgres\", \"sql\", \"locking\", \"transactions\", \"node\", \"pg\"],\n  \"canonical_url\": \"https://sevic.dev/notes/database-locking-for-update/\"\n};\nvar layoutProps = {\n  _frontmatter: _frontmatter\n};\nvar MDXLayout = \"wrapper\";\nreturn function MDXContent(_ref) {\n  var components = _ref.components,\n      props = _objectWithoutProperties(_ref, [\"components\"]);\n\n  return mdx(MDXLayout, _extends({}, layoutProps, props, {\n    components: components,\n    mdxType: \"MDXLayout\"\n  }), mdx(\"p\", null, \"Two concurrent transactions that each \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SELECT\"), \" a row, change it in memory, then \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"UPDATE\"), \" can lose an update or claim the same resource twice. Postgres does not serialize that read-modify-write path unless you ask for a row lock.\"), mdx(\"p\", null, mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SELECT \\u2026 FOR UPDATE\"), \" takes a \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"pessimistic\"), \" row lock until the transaction commits or rolls back. Other transactions that try to lock the same row wait, fail immediately (\", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"NOWAIT\"), \"), or move on (\", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SKIP LOCKED\"), \").\"), mdx(\"p\", null, \"This post covers the race, \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"FOR UPDATE\"), \" / \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"NOWAIT\"), \" / \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SKIP LOCKED\"), \" in raw SQL, the same flow with \", mdx(\"a\", _extends({\n    parentName: \"p\"\n  }, {\n    \"href\": \"https://node-postgres.com/\"\n  }), mdx(\"inlineCode\", {\n    parentName: \"a\"\n  }, \"node-pg\")), \", a short contrast with optimistic locking, and pitfalls.\"), mdx(\"h3\", {\n    \"id\": \"prerequisites\"\n  }, \"Prerequisites\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"Node.js version 26\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, \"PostgreSQL (demo uses Docker Compose, or see \", mdx(\"a\", _extends({\n    parentName: \"li\"\n  }, {\n    \"href\": \"https://sevic.dev/notes/postgres-redis-docker-compose/\"\n  }), \"Postgres and Redis containers with Docker Compose\"), \")\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"npm i pg\"))), mdx(\"h3\", {\n    \"id\": \"the-race-without-a-lock\"\n  }, \"The race without a lock\"), mdx(\"p\", null, \"Account balance starts at \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"100\"), \". Two workers each intend to debit \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"10\"), \". Done one after the other, that is \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"100 \\u2192 90 \\u2192 80\"), \".\"), mdx(\"p\", null, \"What goes wrong is \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"read \\u2192 compute in the app \\u2192 write an absolute value\"), \", with no row lock:\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-text\"\n  }), \"Worker A                         Worker B\\n--------                         --------\\nreads balance = 100\\n                                 reads balance = 100\\nnext = 100 - 10 = 90             next = 100 - 10 = 90\\nUPDATE SET balance = 90\\nCOMMIT  (balance is now 90)\\n                                 UPDATE SET balance = 90\\n                                 COMMIT  (balance still 90)\\n\")), mdx(\"p\", null, \"Both workers applied \\u201Cset to 90\\u201D based on the same stale read. The second debit never subtracted from \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"90\"), \", so the stored balance is \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"90\"), \" instead of the intended \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"80\"), \".\"), mdx(\"p\", null, mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"UPDATE \\u2026 SET balance = balance - 10\"), \" in SQL would avoid this particular race. The bug appears when the app does read \\u2192 compute \\u2192 write an absolute value. Same pattern shows up when two workers claim one pending job with \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SELECT \\u2026 WHERE status = 'pending' LIMIT 1\"), \" and then \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"UPDATE \\u2026 SET status = 'claimed'\"), \".\"), mdx(\"h3\", {\n    \"id\": \"select--for-update\"\n  }, \"SELECT \\u2026 FOR UPDATE\"), mdx(\"p\", null, \"Lock the row inside an \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"explicit transaction\"), \". The lock lives only until \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"COMMIT\"), \" or \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"ROLLBACK\"), \".\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-sql\"\n  }), \"BEGIN;\\n\\nSELECT balance\\nFROM accounts\\nWHERE id = 1\\nFOR UPDATE;\\n\\nUPDATE accounts\\nSET balance = balance - 10\\nWHERE id = 1;\\n\\nCOMMIT;\\n\")), mdx(\"p\", null, \"While the first transaction holds the lock, a second \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SELECT \\u2026 FOR UPDATE\"), \" on the same row \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"waits\"), \". After commit, the second transaction sees the updated balance.\"), mdx(\"h3\", {\n    \"id\": \"nowait\"\n  }, \"NOWAIT\"), mdx(\"p\", null, \"Fail immediately if the row is already locked instead of waiting:\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-sql\"\n  }), \"SELECT balance\\nFROM accounts\\nWHERE id = 1\\nFOR UPDATE NOWAIT;\\n\")), mdx(\"p\", null, \"Postgres raises an error (\", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"could not obtain lock on row\"), \"). Useful when you prefer to retry or return \\u201Cbusy\\u201D rather than block a request thread.\"), mdx(\"h3\", {\n    \"id\": \"skip-locked\"\n  }, \"SKIP LOCKED\"), mdx(\"p\", null, \"Skip rows that other transactions already locked. Typical for \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"job queues\"), \": each worker claims a different pending row without waiting.\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-sql\"\n  }), \"BEGIN;\\n\\nSELECT id, payload\\nFROM jobs\\nWHERE status = 'pending'\\nORDER BY id\\nFOR UPDATE SKIP LOCKED\\nLIMIT 1;\\n\\nUPDATE jobs\\nSET status = 'claimed', claimed_at = now()\\nWHERE id = $1;\\n\\nCOMMIT;\\n\")), mdx(\"p\", null, \"An empty result can mean \\u201Cno pending jobs\\u201D \", mdx(\"strong\", {\n    parentName: \"p\"\n  }, \"or\"), \" \\u201Cevery pending job is locked by another worker right now.\\u201D Decide whether to treat that as idle or as a short retry.\"), mdx(\"h3\", {\n    \"id\": \"with-node-pg\"\n  }, \"With node-pg\"), mdx(\"p\", null, \"Use one client per transaction (\", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"pool.connect()\"), \"), not interleaved queries on a shared pool client:\"), mdx(\"pre\", null, mdx(\"code\", _extends({\n    parentName: \"pre\"\n  }, {\n    \"className\": \"language-js\"\n  }), \"import pg from 'pg';\\n\\nconst pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });\\n\\nasync function debit(accountId, amount) {\\n  const client = await pool.connect();\\n\\n  try {\\n    await client.query('BEGIN');\\n\\n    const { rows } = await client.query(\\n      `SELECT balance\\n       FROM accounts\\n       WHERE id = $1\\n       FOR UPDATE`,\\n      [accountId]\\n    );\\n\\n    if (!rows[0] || rows[0].balance < amount) {\\n      throw new Error('Insufficient funds');\\n    }\\n\\n    await client.query(\\n      `UPDATE accounts\\n       SET balance = balance - $1\\n       WHERE id = $2`,\\n      [amount, accountId]\\n    );\\n\\n    await client.query('COMMIT');\\n  } catch (error) {\\n    await client.query('ROLLBACK');\\n    throw error;\\n  } finally {\\n    client.release();\\n  }\\n}\\n\")), mdx(\"p\", null, \"Claim a job with \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SKIP LOCKED\"), \" the same way: \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"BEGIN\"), \" \\u2192 \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"SELECT \\u2026 FOR UPDATE SKIP LOCKED LIMIT 1\"), \" \\u2192 \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"UPDATE\"), \" \\u2192 \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"COMMIT\"), \" on that client.\"), mdx(\"h3\", {\n    \"id\": \"optimistic-vs-pessimistic\"\n  }, \"Optimistic vs pessimistic\"), mdx(\"table\", null, mdx(\"thead\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"thead\"\n  }, mdx(\"th\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), \"Approach\"), mdx(\"th\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), \"Idea\"))), mdx(\"tbody\", {\n    parentName: \"table\"\n  }, mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), mdx(\"strong\", {\n    parentName: \"td\"\n  }, \"Pessimistic (\", mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"FOR UPDATE\"), \")\")), mdx(\"td\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), \"Lock the row before changing it; others wait or skip\")), mdx(\"tr\", {\n    parentName: \"tbody\"\n  }, mdx(\"td\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), mdx(\"strong\", {\n    parentName: \"td\"\n  }, \"Optimistic (\", mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"version\"), \" column)\")), mdx(\"td\", _extends({\n    parentName: \"tr\"\n  }, {\n    \"align\": null\n  }), mdx(\"inlineCode\", {\n    parentName: \"td\"\n  }, \"UPDATE \\u2026 WHERE id = $1 AND version = $2\"), \"; retry if zero rows updated\")))), mdx(\"p\", null, \"Reach for \", mdx(\"inlineCode\", {\n    parentName: \"p\"\n  }, \"FOR UPDATE\"), \" when contention is expected and you must not double-apply (money, inventory, exclusive claims). Optimistic locking avoids holding locks during long work but needs a retry loop.\"), mdx(\"p\", null, \"Redis locks and Postgres advisory locks are out of scope here - different tools for different coordination problems.\"), mdx(\"h3\", {\n    \"id\": \"pitfalls\"\n  }, \"Pitfalls\"), mdx(\"ul\", null, mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"No transaction, no lock\"), \" - \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"FOR UPDATE\"), \" outside a transaction is released as soon as the statement finishes; wrap \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"BEGIN\"), \"/\", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"COMMIT\"), \".\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Keep the critical section short\"), \" - do not hold the lock while calling external APIs; claim/update, then work after commit when you can.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, \"Lock only the rows you need\"), \" - filter with indexed \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"WHERE\"), \" / \", mdx(\"inlineCode\", {\n    parentName: \"li\"\n  }, \"LIMIT\"), \" so you do not lock a large scan.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"SKIP LOCKED\"), \" empty set\"), \" - not always \\u201Cqueue empty\\u201D; candidates may all be locked.\"), mdx(\"li\", {\n    parentName: \"ul\"\n  }, mdx(\"strong\", {\n    parentName: \"li\"\n  }, mdx(\"inlineCode\", {\n    parentName: \"strong\"\n  }, \"NOWAIT\"), \" is an error\"), \" - handle the lock-not-available SQLSTATE in your app.\")));\n}\n;\nMDXContent.isMDXComponent = true;"}},"pageContext":{"id":"5f745604-5cd8-566a-8dc0-0e4f3a4e07fb"}},"staticQueryHashes":["1961101537","2542493696"]}