PL/SQL & T-SQL (Oracle and SQL Server) Module 12 – Triggers in Oracle
Compound triggers and the mutating-table error
See why a row trigger that reads its own table fails with ORA-04091, enforce a cross-row rule with a compound trigger, and write DDL and logon triggers safely.
What you will learn
- Explain why a row trigger that queries its own table raises ORA-04091
- Rewrite a cross-row rule as a compound trigger that records rows and checks once per statement
- Write a schema DDL trigger and a logon trigger that cannot lock users out
- Show why an autonomous transaction hides the mutating-table error instead of fixing it
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why a row trigger that queries its own table raises ORA-04091
- Rewrite a cross-row rule as a compound trigger that records rows and checks once per statement
- Write a schema DDL trigger and a logon trigger that cannot lock users out
- Show why an autonomous transaction hides the mutating-table error instead of fixing it
Before you start
On this page
A row trigger that keeps each customer’s accounts at or below 50,000 rupees by summing their balances fails on the
first UPDATE it meets: 10 % interest on the 3 savings accounts stops with ORA-04091, the
mutating table: A table that a DML statement is in the middle of changing. A row-level trigger fired by that statement may neither query nor change it; trying raises ORA-04091. error, although every customer would stay under the cap. The
trigger read the table its own statement was changing, which Oracle forbids.
You will see why Oracle raises the mutating-table error for that read, rewrite the rule as a compound trigger that records rows as they change and checks the finished result once, and meet the triggers that fire for schema changes and logons.
A row trigger cannot read the table its statement is changing
The cap spans rows: a CHECK constraint sees one row, but the rule is about the sum of a customer’s accounts. The
first attempt is a row trigger on accounts that runs SELECT SUM(balance) for the customer of each changed row and
raises an error above 50,000:
-- Engine: Oracle AI Database 26ai Free, Release 23.26.3.0.0; user LAB in FREEPDB1. Client: SQL*Plus or SQLcl.
-- A cross-row rule written as a row trigger: a customer's accounts together may hold at most 50000 rupees. The
-- trigger sums the customer's balances in the very table its statement is changing, so Oracle raises ORA-04091
-- and rolls back both the trigger's work and the UPDATE. examples/model/mutating_model.py prints the totals and
-- outcomes described below.
SET SERVEROUTPUT ON
CREATE OR REPLACE TRIGGER accounts_cap_row
AFTER INSERT OR UPDATE OF balance ON accounts
FOR EACH ROW
DECLARE
v_total accounts.balance%TYPE;
BEGIN
SELECT SUM(balance) INTO v_total -- reads the table this statement is changing
FROM accounts
WHERE customer_id = :NEW.customer_id;
IF v_total > 50000 THEN
RAISE_APPLICATION_ERROR(-20040, 'Customer ' || :NEW.customer_id || ' would hold ' || v_total);
END IF;
END;
/
-- 10 % interest on the 3 savings accounts: every customer stays under the cap, yet the statement fails.
UPDATE accounts SET balance = balance * 1.10 WHERE kind = 'savings';
-- Expected:
-- ORA-04091: table LAB.ACCOUNTS is mutating, trigger/function may not see it
-- (followed by ORA-06512 and ORA-04088 lines that name LAB.ACCOUNTS_CAP_ROW)
SELECT account_id, balance FROM accounts ORDER BY account_id;
-- Expected: 2500, 18000, 760.25 and 41250.5: the UPDATE was rolled back along with the trigger's work
DROP TRIGGER accounts_cap_row; """mutating_table.sql in the lab: SQL*Plus runs it as LAB on a freshly loaded practice schema."""
from oracle_session import Lab
with Lab() as lab:
lab.sqlplus("mutating/mutating_table.sql", errors=1) Output
$ sqlplus -s lab@//localhost/FREEPDB1 @mutating/mutating_table.sql
Trigger created.
UPDATE accounts SET balance = balance * 1.10 WHERE kind = 'savings'
*
ERROR at line 1:
ORA-04091: table LAB.ACCOUNTS is mutating, trigger/function may not see it
ORA-06512: at "LAB.ACCOUNTS_CAP_ROW", line 4
ORA-04088: error during execution of trigger 'LAB.ACCOUNTS_CAP_ROW'
Help: https://docs.oracle.com/error-help/db/ora-04091/
ACCOUNT_ID BALANCE
---------- ----------
1 2500
2 18000
3 760.25
4 41250.5
Trigger dropped.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mutating_table_session.py
Oracle’s trigger restrictions page calls a table mutating while a DML statement is changing it, and a row-level trigger fired by that statement may neither query nor change it. The attempt raises ORA-04091, and the database rolls back both the trigger’s work and the triggering statement, so the 3 savings balances stay as they were.
The reason is the half-changed table. Step through customer 1’s sweep, one UPDATE that empties the savings account
and adds 32,000 to the current account:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- One UPDATE, one allowed result. Customer 1's savings of 2500 is swept to 0 and the current account gets 32000 more, a salary of 29500 plus the swept 2500. When the statement ends, customer 1 holds 50000, exactly the cap.
- Account 1 first. If the database changes account 1 first, the table briefly holds 0 plus 18000. A row trigger that summed customer 1 now would see 18000, within the cap.
- Then account 2. After the second row the table holds 0 plus 50000, the final result. In this order both reads are within the cap, and the check passes.
- The other order. If the database changes account 2 first, the table briefly holds 2500 plus 50000. The trigger would see 52500, over the cap, and reject a statement whose result is allowed.
- Why Oracle refuses the read. SQL does not promise an order for the rows of one statement, so a half-changed total belongs to no moment a user could see. Oracle forbids the read with ORA-04091 and rolls back the trigger's work and the statement.
Read halfway, the same statement shows 18,000 in one row order and 52,500 in the other, while its result is 50,000. SQL does not fix the order in which one statement changes its rows, so a check made between two rows could pass or fail by chance. Oracle refuses the read instead.
Checkpoint
Check: Why a row trigger may not read its own table
A compound trigger records per row and checks once
A compound trigger: One trigger with up to four timing-point sections, before and after the statement and before and after each row, that share variables living only for the triggering statement. is one trigger with up to four timing-point sections,
BEFORE STATEMENT, BEFORE EACH ROW, AFTER EACH ROW and AFTER STATEMENT, whose variables live for one triggering
statement. Oracle’s DML triggers page names avoiding the mutating-table error as one of two common uses of compound
triggers. The fix splits the work: the row section records which customers changed, and the statement section, which
the restriction does not cover, sums those customers once the table is whole again:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- State starts with the statement. When the UPDATE starts, the trigger's declared variables are created empty. They belong to this one statement and are dropped when it ends, even when it fails.
- AFTER EACH ROW only records. For account 1 the row section notes customer 1 and does not query accounts, so the mutating-table rule is never touched. For account 2 it notes customer 1 again, which changes nothing.
- AFTER STATEMENT reads the result. Once every row has changed, the statement section sums customer 1 once. The restriction covers row-level code only, so the read is allowed, and it sees the result the user will see, 50000. The statement completes.
- A deposit that breaks the cap. A new statement adds 10000 to account 4. The state starts empty again, the row section notes customer 4, and the statement section finds 51250.50, above the cap.
- The error rolls the deposit back. The statement section raises ORA-20040 with RAISE_APPLICATION_ERROR, and the UPDATE fails as a whole, so account 4 keeps 41250.50. The state is dropped with the statement.
The model runs the row trigger that fails, the half-changed reads of the sweep and the compound approach, which passes
the interest credit (20,750.00, 836.28 and 45,375.55) and the sweep (50,000.00) and rejects the deposit that would
take customer 4 to 51,250.50. Press Edit, change an amount in STATEMENTS, and run it again:
"""Why a row trigger may not read its own table mid-statement, and how a compound trigger checks once.
The rule: a customer's accounts together may hold at most 50000 rupees. mutating_table.sql enforces it
with a row trigger that sums the customer's balances, and Oracle refuses that read with ORA-04091. This
model shows why the read is refused: it applies one UPDATE row by row, in both orders a database might
use, and prints the total a row trigger would see after each row. Then it runs the compound-trigger
approach: record each touched customer per row, and sum their balances once, after the statement.
Edit STATEMENTS (other amounts, other accounts) and run it again.
"""
from decimal import Decimal, ROUND_HALF_UP
CAP = Decimal("50000.00")
ACCOUNTS = { # account_id: (customer_id, kind, balance), the practice data
1: (1, "savings", Decimal("2500.00")),
2: (1, "current", Decimal("18000.00")),
3: (2, "savings", Decimal("760.25")),
4: (4, "savings", Decimal("41250.50")),
}
def interest(balance):
return (balance * Decimal("1.10")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# (what the UPDATE does, {account_id: new balance from old balance}) in the order the rows are listed
STATEMENTS = [
("10 % interest on the 3 savings accounts", {1: interest, 3: interest, 4: interest}),
("customer 1: sweep savings into current, plus a 29500 salary",
{1: lambda b: Decimal("0.00"), 2: lambda b: b + Decimal("32000.00")}),
("deposit 10000 into account 4", {4: lambda b: b + Decimal("10000.00")}),
]
def total(table, customer):
return sum((bal for cust, _, bal in table.values() if cust == customer), Decimal("0.00"))
print("1. A row trigger that sums the customer's balances")
for text, changes in STATEMENTS:
print(f" {text}: ORA-04091 at the first row; the UPDATE is rolled back")
print("2. If a row trigger could read the half-changed table: the sweep, in two row orders")
text, changes = STATEMENTS[1]
for order in ([1, 2], [2, 1]):
table = dict(ACCOUNTS)
seen = []
for account in order:
cust, kind, bal = table[account]
table[account] = (cust, kind, changes[account](bal))
seen.append(total(table, cust))
verdicts = ["over the cap" if t > CAP else "within" for t in seen]
steps = ", then ".join(f"{t} ({v})" for t, v in zip(seen, verdicts))
print(f" rows {order[0]} then {order[1]}: the trigger sees {steps}")
print("3. A compound trigger: record customers in AFTER EACH ROW, sum them in AFTER STATEMENT")
for text, changes in STATEMENTS:
table = dict(ACCOUNTS)
touched = []
for account, change in changes.items(): # AFTER EACH ROW
cust, kind, bal = table[account]
table[account] = (cust, kind, change(bal))
if cust not in touched:
touched.append(cust)
totals = {c: total(table, c) for c in touched} # AFTER STATEMENT: the table is whole again
over = [c for c, t in totals.items() if t > CAP]
shown = ", ".join(f"customer {c} {t}" for c, t in totals.items())
outcome = f"ORA-20040 for customer {over[0]}; the UPDATE is rolled back" if over else "passes"
print(f" {text}: {shown} -> {outcome}") Output
1. A row trigger that sums the customer's balances 10 % interest on the 3 savings accounts: ORA-04091 at the first row; the UPDATE is rolled back customer 1: sweep savings into current, plus a 29500 salary: ORA-04091 at the first row; the UPDATE is rolled back deposit 10000 into account 4: ORA-04091 at the first row; the UPDATE is rolled back 2. If a row trigger could read the half-changed table: the sweep, in two row orders rows 1 then 2: the trigger sees 18000.00 (within), then 50000.00 (within) rows 2 then 1: the trigger sees 52500.00 (over the cap), then 50000.00 (within) 3. A compound trigger: record customers in AFTER EACH ROW, sum them in AFTER STATEMENT 10 % interest on the 3 savings accounts: customer 1 20750.00, customer 2 836.28, customer 4 45375.55 -> passes customer 1: sweep savings into current, plus a 29500 salary: customer 1 50000.00 -> passes deposit 10000 into account 4: customer 4 51250.50 -> ORA-20040 for customer 4; the UPDATE is rolled back
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mutating_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
Four related tools are worth knowing by name. A plain AFTER statement trigger may also read the finished table,
but unlike compound triggers it cannot tell which customers changed, so it must check them all. A temporary table,
written by a row trigger and read by a statement trigger, is the older documented route to the same result.
system trigger: A trigger created on a schema or on the database that fires for a DDL statement, such as CREATE or DROP, or for a database event, such as a logon, rather than for changed rows. fire for DDL in a schema or for database events such as a logon,
not for changed rows. An autonomous transaction: A transaction of its own that a routine marked PRAGMA AUTONOMOUS_TRANSACTION runs while the main transaction waits; its COMMIT keeps its changes whatever the caller does afterwards. More in “Error logging, backtraces and autonomous logs” silences ORA-04091 in a row
trigger, but its query reads only committed data, so the check works on the wrong totals.
Key idea
A row trigger sees its table halfway through the statement, so it may not read it. Record what changed, row by row, and check the finished result once, after the statement.
Checkpoint
Check: Record per row, check once after the statement
The 50,000-rupee cap as a compound trigger, tested on six statements Pro: opens with a Pro, Premium or Ultimate pass
The compound trigger below enforces the cap for every insert and every change of a balance or an owner, and six statements test it on the practice accounts. Oracle’s rules for each section explain the way it is written.
In this part:
- Recording customers in an associative array keyed by id
- Worked numbers: six statements through the trigger
- A race between two sessions, with numbers
- The rules each section obeys
3 runnable examples (Python, SQL) · about 8 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).
DDL and logon triggers, and why an autonomous transaction only hides ORA-04091 Pro: opens with a Pro, Premium or Ultimate pass
Triggers can fire for schema changes and logons as well as for rows, and each kind can be written so that a mistake in it stays harmless. An autonomous transaction, a common workaround for ORA-04091, is then shown to check the wrong totals.
In this part:
- A schema trigger that logs every DDL statement
- A logon trigger that fails can lock users out: reproduce it in a lab
- An autonomous transaction hides ORA-04091 and checks the wrong numbers
5 runnable examples (Python, SQL) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: predict what each trigger does
Each question shows a trigger and a statement. Decide what the trigger may read and when, then read why each answer is right or wrong:
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2–5 years): what is a mutating table, and which triggers does the rule apply to?
A table that a DML statement is in the middle of changing. The rule binds the row-level triggers fired by that statement: they may not query or change the table, and trying raises ORA-04091, which rolls back the trigger and the statement. Statement-level triggers and a compound trigger’s statement sections may read it, because no row is halfway through a change then.
Three longer questions follow, on a cap that fails in its first test, a fix that made the error disappear, and a logon trigger to add safely; work out your answer before you open each first step:
-
Mid to senior
A row trigger on accounts keeps each customer's total at or below a cap with SELECT SUM(balance) for the row's customer. Its first test, a 10 % interest UPDATE, fails with ORA-04091 although no customer goes over. Explain the error and rewrite the check.
-
Mid to staff
To get past ORA-04091, a teammate added PRAGMA AUTONOMOUS_TRANSACTION to the row trigger that enforces a balance cap. The error is gone and the existing tests pass. What would you check, and what would you ship instead?
-
Senior to staff
You must add an AFTER LOGON ON DATABASE trigger that records every new session. How do you make sure a bug in it cannot lock users out, and how do you test it before anyone depends on it?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What is a mutating table, and what may a row trigger not do with it?
A table that a DML statement is changing. A row-level trigger fired by that statement may neither query nor change it; trying raises ORA-04091, and the database rolls back both the trigger's work and the triggering statement.
-
Why does Oracle refuse the read rather than allow it?
The trigger would see the table half changed, and SQL does not fix the order in which one statement changes its rows: a sweep whose result is 50,000 shows 18,000 in one row order and 52,500 in the other, so a check made between two rows could pass or fail by chance.
-
Why can a CHECK constraint not enforce a cap on the sum of a customer's accounts?
A CHECK constraint sees one row, and the rule spans rows: it is about the total of all the customer's accounts.
-
Which sections can a compound trigger have, and how long do its variables live?
Up to four timing points in one trigger: BEFORE STATEMENT, BEFORE EACH ROW, AFTER EACH ROW and AFTER STATEMENT. Its variables live for one triggering statement, so each statement starts with an empty record.
-
How does a compound trigger enforce a cross-row rule?
The row section records which keys changed, and the AFTER STATEMENT section, which the restriction does not cover, sums just those keys once the table is whole again.
-
What can a plain AFTER statement trigger not do that a compound trigger can?
It may also read the finished table, but it cannot tell which keys changed, so it has to check them all.
-
Why is an autonomous transaction not a fix for ORA-04091?
It silences the error, but its query reads only committed data, so the check works on the wrong totals.
-
What do system triggers fire for?
For DDL in a schema or for database events such as a logon, not for changed rows.
Key takeaways
- A row trigger may not query or change the table its statement is changing, and trying raises ORA-04091 and rolls the statement back.
- Read halfway, one statement shows totals that depend on row order, such as 18,000 or 52,500 for a result of 50,000.
- A compound trigger records what changed in its row sections and checks the finished table once, in
AFTER STATEMENT. - A compound trigger’s variables live for one triggering statement, so each statement starts with an empty record.
- System triggers fire for DDL and database events, and an autonomous transaction silences ORA-04091 without making the check right.
Terms in this lesson
- compound trigger
- One trigger with up to four timing-point sections, before and after the statement and before and after each row, that share variables living only for the triggering statement.
- mutating table
- A table that a DML statement is in the middle of changing. A row-level trigger fired by that statement may neither query nor change it; trying raises ORA-04091.
- system trigger
- A trigger created on a schema or on the database that fires for a DDL statement, such as CREATE or DROP, or for a database event, such as a logon, rather than for changed rows.
References
- DML Triggers (PL/SQL Language Reference) (Oracle)
- Trigger Restrictions (Oracle)
- ORA-04091 (Database Error Messages) (Oracle)
- System Triggers (Oracle)
- Exception Handling in Triggers (Oracle)
- Triggers for Publishing Events (event attribute functions) (Oracle)
- CREATE TRIGGER Statement (Oracle)
- Autonomous Transactions (Oracle)
- Data Concurrency and Consistency (Database Concepts) (Oracle)
Report a problem with this lesson
Kept only in this browser. Your Learn progress