PL/SQL & T-SQL (Oracle and SQL Server) Module 15 – Production PL/SQL: staging, concurrency, JSON and tests
Row locks, SKIP LOCKED and scheduler jobs
Read committed data while rows are locked, take row locks with NOWAIT, WAIT or SKIP LOCKED, stop lost updates and run work as scheduler jobs.
What you will learn
- Explain Oracle's multiversion read consistency
- Choose between waiting, NOWAIT, WAIT n and SKIP LOCKED for a locked row
- Prevent lost updates with optimistic checks
- Run background work with DBMS_SCHEDULER
Before you start
What you’ll learn · Before you start
What you will learn
- Explain Oracle's multiversion read consistency
- Choose between waiting, NOWAIT, WAIT n and SKIP LOCKED for a locked row
- Prevent lost updates with optimistic checks
- Run background work with DBMS_SCHEDULER
Before you start
On this page
Three workers take payment jobs from one queue table of six jobs. Without row locks two of them can take the same job and send one payment twice; with a plain FOR UPDATE the second worker waits for the first; with SKIP LOCKED each takes a different job at once.
You will see why a reader in Oracle never waits for a writer, choose what a session does when it meets a locked row, and meet the tools that put row locks to work: a shared job queue, checks that stop lost updates, scheduler jobs that run work in the background, and a way to change one busy number without row locks.
Readers see committed data while a writer holds the row
Oracle’s Concepts guide states the rules in four lines. A row is locked only when a writer changes it, or when
SELECT ... FOR UPDATE asks for its lock. A writer blocks another writer of the same row. Queries and writers, though, never hold
each other up: while a row is being changed, the database uses undo data to give each query the version that was
last committed. That is read consistency: The guarantee that a query sees data as committed at one point in time. While a row is being changed, Oracle builds its last committed version from undo data, so readers never wait for writers., and every query gets
it for a single point in time, so its rows all come from one committed state.
Follow two sessions through the queue table pay_jobs, where job 1 holds the amount 100:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A changes job 1. Session A sets job 1's amount to 999 and does not commit. The row is now locked by A's transaction.
- B reads job 1. Session B runs a plain SELECT of job 1. A query takes no row lock, so it does not wait for A.
- B gets the committed value. The database builds the row as it was at the last commit, from undo data, so B sees the amount 100, not A's 999.
- A rolls back, then locks. Session A rolls back its change, then runs SELECT ... FOR UPDATE on jobs 1 and 2, which locks both rows until A's transaction ends.
- B asks to lock every job. Session B runs SELECT ... FOR UPDATE on all new jobs with no wait clause. Jobs 1 and 2 are locked, so B waits.
- A commits. Session A commits. Its row locks are released, and the database lets B's statement go on.
- B gets every job. B's statement returns jobs 1 to 6, and B now holds their row locks until it commits or rolls back.
Session B’s plain SELECT returned at once with the committed 100 while A’s 999 was still uncommitted. B’s
SELECT ... FOR UPDATE waited, because it asked for row locks that A held. Waiting belongs to locks, never to reading.
Checkpoint
Check: Readers that never wait for writers
Row locks: wait, NOWAIT, WAIT n or SKIP LOCKED
SELECT ... FOR UPDATE locks the rows it selects until the transaction ends, so no other session can change or lock
them first. Oracle’s SELECT page allows it only in a top-level query, not with DISTINCT, set operators, GROUP BY or
aggregate functions. What a session does when it meets row locks held by another transaction depends on one clause:
| Clause | When a selected row is locked by another transaction |
|---|---|
| none | waits until that transaction commits or rolls back |
NOWAIT |
fails at once with ORA-00054 |
WAIT 2 |
waits up to 2 seconds, then fails if the row is still locked (in the lab run, with ORA-00054 too) |
SKIP LOCKED |
leaves that row out, and locks and returns the others |
WAIT counts seconds unless MILLISECONDS or MICROSECONDS follows the number. SKIP LOCKED is built, in the SELECT
page’s words, for multiconsumer queues: each consumer skips the rows other consumers hold and gets the remaining rows
without waiting. The lab script runs every clause in two windows, and the model prints what session B gets while A holds jobs 1
and 2. Press Edit, set HELD = [1, 2, 3, 4, 5, 6] and run it again:
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: two SQL*Plus or SQLcl
-- windows, A and B, both connected as LAB. Run each numbered step in the window it names, in order.
-- Step 1 creates the queue table pay_jobs with six new jobs. The steps then show a reader that does not wait for a
-- writer, and what B gets from SELECT ... FOR UPDATE with each wait clause while A holds jobs 1 and 2.
-- claim_model.py prints the same outcomes. The last step drops the table.
-- 1. Window A: the queue table.
DROP TABLE IF EXISTS pay_jobs PURGE;
CREATE TABLE pay_jobs (
job_id NUMBER(10) CONSTRAINT pay_jobs_pk PRIMARY KEY,
amount NUMBER(14, 2) NOT NULL,
status VARCHAR2(10) DEFAULT 'new' NOT NULL
CONSTRAINT pay_jobs_status_ck CHECK (status IN ('new', 'running', 'done', 'failed')),
attempts NUMBER(3) DEFAULT 0 NOT NULL,
claimed_by VARCHAR2(30)
);
INSERT INTO pay_jobs (job_id, amount) SELECT LEVEL, 100 * LEVEL FROM dual CONNECT BY LEVEL <= 6;
COMMIT;
-- 2. Window A: change job 1 and do not commit.
UPDATE pay_jobs SET amount = 999 WHERE job_id = 1;
-- 3. Window B: a plain query returns at once and reads the committed amount.
SELECT job_id, amount FROM pay_jobs WHERE job_id = 1;
-- Expected: 1, 100. A writer never blocks a reader; the reader sees the last committed value.
-- 4. Window A: undo the change, then lock jobs 1 and 2 for this transaction.
ROLLBACK;
SELECT job_id FROM pay_jobs WHERE job_id IN (1, 2) FOR UPDATE;
-- 5. Window B: each statement meets the two locked rows.
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE NOWAIT;
-- Expected: ORA-00054 at once.
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE WAIT 2;
-- Expected: about 2 seconds pass, then the statement fails because the rows are still locked.
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE SKIP LOCKED;
-- Expected: 3, 4, 5 and 6, now locked by B as well.
ROLLBACK;
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE;
-- Expected: B waits. Go to window A.
-- 6. Window A: end the transaction. B's waiting statement returns 1 to 6 at once.
COMMIT;
-- 7. Window B: release the rows, then drop the table.
ROLLBACK;
DROP TABLE pay_jobs PURGE; """for_update_choices.sql in the lab, in two SQL*Plus clients of LAB, A and B, step by step as the script
numbers them. Step 5 ends with a statement that waits for A's locks; its rows arrive after A's COMMIT in
step 6. The lock error names a transaction by numbers that change on every run, shown as <n>."""
from oracle_session import Lab
STEP = r"^-- \d+\. (?P<who>Window A|Window B|Both windows|Clean-up)\b"
WHO = {"Window A": ["A"], "Window B": ["B"], "Both windows": ["A", "B"], "Clean-up": ["A"]}
MASKS = [(r'by \d+ \("usn<<16 \| slot"\)', 'by <n> ("usn<<16 | slot")'), (r'and \d+ \("sequence"\)', 'and <n> ("sequence")'),
(r"transaction ID(\s+)\d+\.\d+\.\d+", r"transaction ID\1<n>.<n>.<n>")]
with Lab(masks=MASKS) as lab:
lab.steps("locks/for_update_choices.sql", STEP, WHO, users={"A": "lab", "B": "lab"}, waiting={5}, errors=2) Output
A> 1. Window A: the queue table.
Table dropped.
Table created.
6 rows created.
Commit complete.
A> 2. Window A: change job 1 and do not commit.
1 row updated.
B> 3. Window B: a plain query returns at once and reads the committed amount.
JOB_ID AMOUNT
---------- ----------
1 100
A> 4. Window A: undo the change, then lock jobs 1 and 2 for this transaction.
Rollback complete.
JOB_ID
----------
1
2
B> 5. Window B: each statement meets the two locked rows.
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE NOWAIT
*
ERROR at line 1:
ORA-00054: Failed to acquire a lock (Type: "TX", Name: "Transaction",
Description: "Lock held by a transaction to allow other transactions to wait
for it") because it is currently held by another session. The resource being
locked can be identified by <n> ("usn<<16 | slot") and <n> ("sequence")
ORA-40097: Failure to acquire row lock, currently held by transaction ID
<n>.<n>.<n>
Help: https://docs.oracle.com/error-help/db/ora-00054/
SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id FOR UPDATE WAIT 2
*
ERROR at line 1:
ORA-00054: Failed to acquire a lock (Type: "TX", Name: "Transaction",
Description: "Lock held by a transaction to allow other transactions to wait
for it") because it is currently held by another session. The resource being
locked can be identified by <n> ("usn<<16 | slot") and <n> ("sequence")
ORA-40097: Failure to acquire row lock, currently held by transaction ID
<n>.<n>.<n>
Help: https://docs.oracle.com/error-help/db/ora-00054/
JOB_ID
----------
3
4
5
6
Rollback complete.
(B waits)
A> 6. Window A: end the transaction. B's waiting statement returns 1 to 6 at once.
Commit complete.
B> (the statement that waited returns)
JOB_ID
----------
1
2
3
4
5
6
6 rows selected.
B> 7. Window B: release the rows, then drop the table.
Rollback complete.
Table dropped.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 for_update_choices_session.py
"""What a second session gets from SELECT ... FOR UPDATE while a first session holds row locks, modelled in Python.
A model, not Oracle: it applies the rules of Oracle's SELECT page and its Concepts guide. A row is locked only by a
writer or by SELECT ... FOR UPDATE, and the lock lasts until that transaction commits or rolls back. Without a wait
clause the second session waits; NOWAIT fails at once with ORA-00054; WAIT n waits up to n seconds, then fails; SKIP
LOCKED returns and locks the rows nobody else holds. A plain query, without FOR UPDATE, never waits.
Press Edit, change HELD (the jobs session A holds), and run it again.
"""
JOBS = [1, 2, 3, 4, 5, 6] # payment jobs with status 'new'
HELD = [1, 2] # session A: SELECT ... WHERE job_id IN (1, 2) FOR UPDATE, not committed
free = [job for job in JOBS if job not in HELD]
print(f"Session A holds jobs {', '.join(map(str, HELD)) or 'none'} with FOR UPDATE and has not committed.")
print("Session B runs SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id ...")
print()
rows = [
("without FOR UPDATE", f"returns {', '.join(map(str, JOBS))} at once, and locks nothing"),
("FOR UPDATE", "waits until session A commits or rolls back" if HELD else f"returns and locks {', '.join(map(str, JOBS))}"),
("FOR UPDATE NOWAIT", "fails at once with ORA-00054" if HELD else f"returns and locks {', '.join(map(str, JOBS))}"),
("FOR UPDATE WAIT 2", "waits 2 seconds, then fails: the rows are still locked" if HELD else f"returns and locks {', '.join(map(str, JOBS))}"),
("FOR UPDATE SKIP LOCKED", f"returns and locks {', '.join(map(str, free))}" if free else "returns no row at once"),
]
for clause, outcome in rows:
print(f" {clause:<24} {outcome}")
done = True Output
Session A holds jobs 1, 2 with FOR UPDATE and has not committed. Session B runs SELECT job_id FROM pay_jobs WHERE status = 'new' ORDER BY job_id ... without FOR UPDATE returns 1, 2, 3, 4, 5, 6 at once, and locks nothing FOR UPDATE waits until session A commits or rolls back FOR UPDATE NOWAIT fails at once with ORA-00054 FOR UPDATE WAIT 2 waits 2 seconds, then fails: the rows are still locked FOR UPDATE SKIP LOCKED returns and locks 3, 4, 5, 6
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 claim_model.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
When every job is held, SKIP LOCKED returns no row at once, which a worker reads as “nothing to do now”. In 26ai the error pages also describe ORA-40097, which names the transaction holding a row lock by its undo segment, slot and wrap numbers.
Four larger topics follow from these row locks. A SKIP LOCKED: A clause of SELECT ... FOR UPDATE that leaves out the rows another transaction has locked and locks and returns the others, so several consumers of a queue can work without waiting for each other. queue lets several workers
share one table of jobs without taking any job twice. A lost update: A change that disappears because another session, starting from an older read of the same row, writes over it. Both statements succeed, and no error is raised. is a change that
another session overwrites from an older read, and an optimistic locking: A way to prevent lost updates that holds no lock between reading a row and writing it: the write itself checks that the row has not changed since it was read. in the
UPDATE stops it. scheduler job: A job of Oracle Scheduler, created with DBMS_SCHEDULER.CREATE_JOB, that runs PL/SQL or a program in the background, once or on a calendar, and logs each run. run PL/SQL in the background on a calendar, and
scheduler jobs log each run in USER_SCHEDULER_JOB_RUN_DETAILS. A lock-free reservation lets many sessions change
one busy number, such as the stock of a best seller, without waiting for each other’s row locks.
Checkpoint
Check: Waiting, NOWAIT, WAIT n and SKIP LOCKED
A job queue that several workers share with SKIP LOCKED Pro: opens with a Pro, Premium or Ultimate pass
A queue table needs two promises: no job is taken by two workers, and no job is lost when a worker dies. Below, the claim is built to keep both, and a run of three workers, one of which crashes, is replayed step by step.
- Claiming one job at a time, step by step
- A worker that crashes, and a job that always fails
3 runnable examples (Python, SQL) · about 4 minutes
Opening this part with your pass…
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Lost updates reproduced, and the checks that prevent them Pro: opens with a Pro, Premium or Ultimate pass
A lost update leaves no error behind: two sessions both succeed, and one of the changes is gone. Below, one is reproduced with real amounts, and three ways to prevent it are compared by how long the read stays open.
In this part:
- Two writes from one old read lose a withdrawal
- Choosing among the three checks for your own case
- Where a version check stops protecting a row
3 runnable examples (Python, SQL) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Scheduler jobs that keep the queue running, and lock-free reservations Pro: opens with a Pro, Premium or Ultimate pass
The sweep of the queue must run every few minutes whether or not anyone is watching. Below it becomes a scheduler job whose calendar is checked before it is enabled, followed by a way for many sessions to change one hot number without waiting for each other’s locks.
In this part:
- A job that runs on a calendar, checked before it is enabled
- Lock-free reservations for a hot number such as stock
3 runnable examples (Python, SQL) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: reads, waits and skips
Each question describes two sessions and one row. Say who waits, who fails and what each one reads:
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what is the practical difference between NOWAIT and WAIT n on a locked row?
Both stop a SELECT … FOR UPDATE from waiting without limit for another transaction’s row lock. NOWAIT fails at once with ORA-00054, which suits a screen that should say “someone else is editing this” straight away. WAIT n waits up to n seconds, then fails if the row is still locked, which suits a short job that can afford a pause. Without either clause the session waits until the holder commits or rolls back.
Three longer questions follow: a payment sent twice from a queue, seats sold beyond capacity, and a nightly job that stopped without anyone noticing:
-
Mid to senior
Four workers serve a payment queue table. Each selects the oldest job with status 'new' and then, in a second statement, updates it to 'running'. Twice last week one payment was sent two times. Explain the race, and rework the claim so that a job can never be taken twice.
-
Mid to senior
A booking procedure reads seats_left into a variable, checks that it covers the request, and later sets seats_left to the variable minus the seats booked. In a busy sale, 212 seats were sold for an event of 200, and no error appeared. Explain why, and give two fixes.
-
Senior to staff
A nightly DBMS_SCHEDULER job settles the day's card payments. Finance found that it had not run for nine nights, and no alert fired. Where do you look first, what could have stopped it, and how do you make a silent stop impossible to miss?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Does a reader in Oracle ever wait for a writer?
No. While a row is being changed the database uses undo data to give each query the version last committed, and every query gets its rows from one committed point in time. Waiting belongs to locks, never to reading.
-
When is a row locked, and how long does the lock last?
Only when a writer changes it or when SELECT ... FOR UPDATE asks for its lock, and the lock lasts until the transaction commits or rolls back. A writer blocks another writer of the same row.
-
Where may SELECT ... FOR UPDATE not be used?
Only in a top-level query: not with DISTINCT, set operators, GROUP BY or aggregate functions.
-
What do the four lock-wait choices do when a selected row is held by another transaction?
With no clause the session waits until that transaction ends; NOWAIT fails at once with ORA-00054; WAIT 2 waits up to two seconds and then fails if the row is still locked; SKIP LOCKED leaves that row out and locks and returns the others.
-
What unit does WAIT count in?
Seconds, unless MILLISECONDS or MICROSECONDS follows the number.
-
What is SKIP LOCKED built for, and what does it return when every job is held?
Multiconsumer queues: each consumer skips the rows other consumers hold and gets the remaining rows without waiting. When every row is locked it returns no row at once, which a worker reads as nothing to do now.
-
What is a lost update, and what stops it?
A change that another session overwrites from an older read. An optimistic check in the UPDATE, which refuses to write when the row has changed since it was read, stops it.
-
What runs PL/SQL in the background, and where is each run recorded?
Scheduler jobs, which run on a calendar, and each run is logged in USER_SCHEDULER_JOB_RUN_DETAILS.
Key takeaways
- In Oracle a reader never waits for a writer: a query reads the last committed version of each row, from undo data.
- A row is locked only by a writer or by SELECT … FOR UPDATE, and the lock lasts until the transaction ends.
- With no clause FOR UPDATE waits, NOWAIT fails at once, WAIT n waits n seconds, and SKIP LOCKED leaves locked rows out.
- SKIP LOCKED queues and optimistic checks put row locks to work for shared queues and concurrent edits, and scheduler jobs run such work in the background.
Terms in this lesson
- lost update
- A change that disappears because another session, starting from an older read of the same row, writes over it. Both statements succeed, and no error is raised.
- optimistic locking
- A way to prevent lost updates that holds no lock between reading a row and writing it: the write itself checks that the row has not changed since it was read.
- read consistency
- The guarantee that a query sees data as committed at one point in time. While a row is being changed, Oracle builds its last committed version from undo data, so readers never wait for writers.
- scheduler job
- A job of Oracle Scheduler, created with DBMS_SCHEDULER.CREATE_JOB, that runs PL/SQL or a program in the background, once or on a calendar, and logs each run.
- SKIP LOCKED
- A clause of SELECT ... FOR UPDATE that leaves out the rows another transaction has locked and locks and returns the others, so several consumers of a queue can work without waiting for each other.
References
- Database Concepts 26ai: Data Concurrency and Consistency (Oracle)
- SQL Language Reference 26ai: SELECT (Oracle)
- PL/SQL Language Reference 26ai: Transaction Processing and Control (Oracle)
- Database Error Messages: ORA-00054 (Oracle)
- Database Error Messages: ORA-40097 (Oracle)
- PL/SQL Packages and Types Reference 26ai: DBMS_SCHEDULER (Oracle)
- Administrator's Guide 26ai: Scheduling Jobs with Oracle Scheduler (Oracle)
- Database Reference 26ai: ALL_SCHEDULER_JOB_RUN_DETAILS (Oracle)
- PL/SQL Packages and Types Reference 26ai: DBMS_LOCK (Oracle)
- Development Guide 26ai: Using Lock-Free Reservation (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress