A PL/SQL trigger is an automatic PL/SQL block that fires on DML, DDL, or system events
Row-level triggers fire once per affected row; statement-level triggers fire once per statement
:NEW and :OLD pseudorecords give access to row data before and after the change
Mutating table error (ORA-04091) occurs when a row-level trigger queries its own table — fix with compound triggers
DDL triggers audit schema changes; system event triggers (LOGON, SERVERERROR) are invaluable for production observability
Use CALL syntax (12c+) to keep trigger logic in testable procedures, not inline
Plain-English First
A PL/SQL trigger is an automatic action that Oracle fires when something happens to a table — like a guard that watches the door and reacts immediately whenever someone enters or leaves, without you having to call them manually.
A PL/SQL trigger is a stored program that Oracle executes automatically in response to a database event — an INSERT, UPDATE, or DELETE on a table or view, a DDL statement, or a system event like logon or shutdown. Unlike stored procedures, triggers are not called explicitly; they fire in reaction to events. This makes them powerful for enforcing business rules, maintaining audit trails, and synchronising derived data — and dangerous when overused.
I have debugged production systems where a single UPDATE cascaded through 8 triggers across 6 tables, executing 47 trigger bodies before completing. I have seen DDL triggers lock a DBA out of their own schema. I have seen a team lose 3 months of audit data because their audit trigger did not use an autonomous transaction, and a batch rollback wiped the audit log clean. Triggers are a sharp tool — this guide covers everything from the basics to the patterns that prevent these disasters.
What is a PL/SQL Trigger?
A trigger is a named database object containing a PL/SQL block that Oracle executes automatically whenever a specified DML event occurs on a table or view. The trigger fires without any explicit call from the application — you define the condition once, and Oracle enforces it for every matching operation, regardless of which application or user performs the DML.
Triggers are used primarily for: enforcing complex business rules that CHECK constraints cannot express (constraints cannot reference other tables or use session context), maintaining audit tables that record who changed what and when, automatically populating derived columns (e.g., last_modified_by, last_modified_date), and replicating changes to denormalised summary tables.
Triggers execute within the triggering transaction. If the trigger raises an exception, the triggering DML statement is rolled back. If you call COMMIT or ROLLBACK inside a trigger, Oracle raises ORA-04092.
A common misconception is that triggers are always faster than application code. In reality, a trigger adds the overhead of a PL/SQL context switch for every row. For bulk operations, that overhead adds up fast. Always benchmark with realistic data volumes before relying on a trigger for performance-sensitive logic.
Every row-level trigger invocation causes a PL/SQL context switch. For a bulk INSERT of 100,000 rows, that switch happens 100,000 times. If your trigger logic is trivial (one assignment), the context switch dominates execution time. Consider statement-level triggers or disabling triggers during batch loads.
Production Insight
Triggers run inside the calling transaction. If a trigger fails after auditing the change, the audit gets rolled back too — unless you use autonomous transactions.
This means silent audit gaps when triggers fail midway. Always test with rollback scenarios.
Rule: never assume audit data is safe unless the trigger uses PRAGMA AUTONOMOUS_TRANSACTION.
Key Takeaway
A trigger fires automatically within the triggering transaction.
You cannot COMMIT or ROLLBACK inside a trigger.
If the trigger fails, the entire DML statement rolls back.
Trigger Types — BEFORE vs AFTER, Row vs Statement Level
Oracle triggers have two orthogonal dimensions: when they fire (BEFORE or AFTER the DML) and how many times they fire (once per row or once per statement).
BEFORE triggers fire before the DML operation executes. For row-level BEFORE triggers, you can modify the :NEW pseudorecord to change the value being inserted or updated before it hits the table. This is the correct place for defaulting values, reformatting data, and enforcing rules that need to change the incoming data.
AFTER triggers fire after the DML succeeds. You cannot modify :NEW in AFTER triggers. AFTER is the correct choice for auditing (the row is committed, so you know the operation succeeded) and for updating other tables based on the completed change.
Row-level triggers (FOR EACH ROW) fire once per affected row. Statement-level triggers (no FOR EACH ROW) fire once per DML statement regardless of how many rows are affected — even zero rows. For auditing individual row changes, use row-level. For logging that a statement was executed, use statement-level.
A word on combinations: You can have multiple triggers on the same event. For example, a BEFORE row-level trigger for defaulting values and an AFTER row-level trigger for auditing. In Oracle 11g+, you control execution order with FOLLOWS and PRECEDES. Without them, order is alphabetical by trigger name — fragile and silent.
Statement-level triggers are often overlooked. A developer once put a row-level trigger to log 'UPDATE executed on table X' — it fired 10 million times during a bulk update.
Statement-level triggers fire once, regardless of rows affected. Use them for per-statement actions.
Rule: if the action is independent of row count (e.g., log the fact that an UPDATE ran), always use a statement-level trigger.
Key Takeaway
BEFORE triggers can modify :NEW. AFTER triggers cannot.
Row-level fires per row; statement-level fires per statement.
Choose based on whether the logic needs per-row data or just an event signal.
:NEW and :OLD Pseudorecords — Reading Row Data Inside a Trigger
:NEW and :OLD are correlation names that provide access to the row being affected by the DML statement. They are available only in row-level triggers (FOR EACH ROW).
:OLD holds the values of the row before the DML operation. For INSERT, :OLD is NULL for all columns (there was no existing row). For DELETE, :NEW is NULL (the row is being removed). For UPDATE
plsql_triggers_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- Using :NEW and :OLD to enforce a business ruleCREATEORREPLACETRIGGER trg_salary_check
BEFOREUPDATEOF salary ON employees
FOREACHROWWHEN (NEW.salary < OLD.salary)
BEGINIF :NEW.manager_approved != 'Y'THEN
RAISE_APPLICATION_ERROR(
-20001,
'Salary decrease for employee ' || :OLD.employee_id ||
' requires manager approval. Current: ' || :OLD.salary ||
' Requested: ' || :NEW.salary
);
ENDIF;
END;
/
-- Normalise email on insert or updateCREATEORREPLACETRIGGER trg_normalise_email
BEFOREINSERTORUPDATEOF email ON users
FOREACHROWBEGIN
:NEW.email := LOWER(TRIM(:NEW.email));
:NEW.updated_at := SYSTIMESTAMP;
END;
/
Forge Tip
In the WHEN clause, reference the pseudorecords without the colon: WHEN (NEW.salary <> OLD.salary). Inside the PL/SQL block body, use the colon: :NEW.salary. This is a consistent source of syntax errors — I have seen it trip up even experienced developers.
Production Insight
A common bug: referencing :NEW inside a statement-level trigger. Statement-level triggers don't have access to :NEW/:OLD — they fire once for the whole statement.
If you try to use :NEW in a statement-level trigger, Oracle raises ORA-04082. Always confirm the trigger type before using pseudorecords.
Rule: :NEW/:OLD are only valid in row-level triggers (FOR EACH ROW).
Key Takeaway
:NEW holds the new values; :OLD holds the old ones.
You can modify :NEW only in BEFORE triggers.
In AFTER triggers and statement-level triggers, :NEW and :OLD are either NULL or read-only.
The Mutating Table Error (ORA-04091)
ORA-04091 is the most notorious trigger error. It occurs when a row-level trigger tries to query or modify the table that fired the trigger. Oracle raises this error to prevent inconsistent reads during a DML operation that has not yet completed.
Example: an AFTER UPDATE row-level trigger on employees queries SELECT COUNT(*) FROM employees. Oracle cannot allow this — the DML is mid-flight and the table is in an inconsistent state.
Three solutions: 1. Compound triggers (Oracle 11g+): a single trigger with multiple firing points. Collect the IDs in an AFTER EACH ROW section into a package-level collection, then query the table in the AFTER STATEMENT section when the DML is complete. 2. Autonomous transactions: not recommended for this pattern — it processes data in a separate transaction that cannot see uncommitted changes from the triggering transaction. 3. Rethink the design: if a trigger queries the same table, the logic may belong in a stored procedure called from the application, where you have full control over the transaction.
Performance note: Compound triggers collect data in PGA memory. For DML affecting millions of rows, this can cause excessive PGA consumption. Always test with realistic row counts to ensure you don't swap from ORA-04091 to ORA-04030 (out of PGA memory).
plsql_triggers_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
-- Compound trigger solves ORA-04091CREATEORREPLACETRIGGER trg_check_dept_budget
FORUPDATEOF salary ON employees
COMPOUNDTRIGGERTYPE t_id_list ISTABLEOFNUMBERINDEXBY PLS_INTEGER;
v_affected_depts t_id_list;
v_idx PLS_INTEGER := 0;
AFTEREACHROWISBEGIN
v_idx := v_idx + 1;
v_affected_depts(v_idx) := :NEW.department_id;
ENDAFTEREACHROW;
AFTERSTATEMENTIS
v_total_salary NUMBER;
v_budget NUMBER;
BEGINFOR i IN1 .. v_affected_depts.COUNTLOOPSELECTSUM(salary) INTO v_total_salary
FROM employees
WHERE department_id = v_affected_depts(i);
SELECT budget INTO v_budget
FROM departments
WHERE department_id = v_affected_depts(i);
IF v_total_salary > v_budget THEN
RAISE_APPLICATION_ERROR(-20002,
'Salary total exceeds budget for dept ' || v_affected_depts(i));
ENDIF;
ENDLOOP;
ENDAFTERSTATEMENT;
END trg_check_dept_budget;
/
Forge Tip
Compound triggers are the clean solution to ORA-04091. They allow different firing points in a single trigger body. With Oracle 11g+, there is no reason to use the old autonomous-transaction workaround.
Production Insight
ORA-04091 often appears in audit triggers that try to compute aggregates on the same table. I've seen a trigger that tried to maintain a running total — it failed on any multi-row UPDATE.
The compound trigger pattern collects row IDs in AFTER EACH ROW, then does the aggregate query in AFTER STATEMENT. This avoids the mutating table because the table is stable by then.
Rule: never read the triggering table inside a row-level trigger. Use compound triggers for cross-row logic.
Key Takeaway
ORA-04091 happens when a row-level trigger reads its own table.
Compound triggers are the correct fix — use AFTER STATEMENT for table access.
Autonomous transactions are a trap: they can't see uncommitted changes.
INSTEAD OF Triggers — Enabling DML on Views
By default, you cannot perform DML (INSERT, UPDATE, DELETE) on a view that involves joins, aggregate functions, DISTINCT, GROUP BY, or set operators. Oracle raises ORA-01779. INSTEAD OF triggers solve this.
An INSTEAD OF trigger fires in place of the DML operation on the view. Oracle intercepts the INSERT/UPDATE/DELETE and executes your trigger code instead of attempting to modify the view directly. Your trigger code then updates the underlying base tables explicitly.
This is the standard pattern for updatable views in Oracle. It lets the application interact with a simplified, business-friendly view while the trigger handles the complexity of distributing the DML across the underlying tables.
A common mistake: creating INSTEAD OF triggers for only one DML type. If an application issues DELETE on a view and only an INSERT trigger exists, Oracle raises ORA-01779 again. Always create triggers for all DML operations that the application will use.
plsql_triggers_example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
-- A join view that is not directly updatableCREATEORREPLACEVIEW v_emp_dept ASSELECT e.employee_id, e.first_name, e.last_name,
e.salary, d.department_name, d.location_id
FROM employees e
JOIN departments d ON e.department_id = d.department_id;
-- INSTEAD OF INSERT triggerCREATEORREPLACETRIGGER trg_v_emp_dept_ins
INSTEADOFINSERTON v_emp_dept
FOREACHROWDECLARE
v_dept_id departments.department_id%TYPE;
BEGINSELECT department_id INTO v_dept_id
FROM departments
WHERE department_name = :NEW.department_name;
INSERTINTOemployees (
employee_id, first_name, last_name, salary, department_id
) VALUES (
employees_seq.NEXTVAL,
:NEW.first_name, :NEW.last_name,
:NEW.salary, v_dept_id
);
EXCEPTIONWHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20003,
'Department not found: ' || :NEW.department_name);
END;
/
-- INSTEAD OF UPDATE triggerCREATEORREPLACETRIGGER trg_v_emp_dept_upd
INSTEADOFUPDATEON v_emp_dept
FOREACHROWBEGINUPDATE employees
SET first_name = :NEW.first_name,
last_name = :NEW.last_name,
salary = :NEW.salary
WHERE employee_id = :OLD.employee_id;
END;
/
-- INSTEAD OF DELETE triggerCREATEORREPLACETRIGGER trg_v_emp_dept_del
INSTEADOFDELETEON v_emp_dept
FOREACHROWBEGINDELETEFROM employees
WHERE employee_id = :OLD.employee_id;
END;
/
Production Insight
INSTEAD OF triggers are often forgotten when a view becomes non-updatable due to a new join. A team once added a left join to a view for reporting, and the next morning the application could no longer update through that view.
The fix is to create INSTEAD OF triggers for each DML type, or better, use a procedure to update the base tables directly.
Rule: whenever you change a view's query to include joins, aggregate, or DISTINCT, check if any application code does DML on that view.
Key Takeaway
INSTEAD OF triggers make non-updatable views updatable.
They intercept DML and manually apply changes to base tables.
Use them sparingly — each DML type needs its own trigger.
DDL Triggers — Auditing Schema Changes
DDL triggers fire in response to CREATE, ALTER, DROP, GRANT, REVOKE, or TRUNCATE statements on schema objects. They are the DBA tool for tracking who changed what in the database schema.
Three essential DDL trigger use cases: 1. Audit logging: Record every DDL change (who ran it, when, what SQL) into a schema_change_log table. When someone drops a table at 2am and nobody knows who did it, this log is your only evidence. 2. Enforce naming conventions: Reject table names without a prefix, reject columns named ID without a table prefix. 3. Prevent accidental drops: Block DROP TABLE, DROP INDEX, DROP SEQUENCE in production schemas.
The ORA-04045 trap: If your DDL trigger has a compilation error, Oracle cannot compile it. Since DDL triggers fire on CREATE/ALTER, you cannot create or alter ANY object in the schema until you fix the trigger. Always test DDL triggers in dev first.
Performance impact: DDL triggers add latency to every DDL command. If you have hundreds of developers running migrations, a slow DDL trigger can cause timeouts. Keep the body fast — avoid queries to remote tables or heavy validation.
If your DDL trigger has a compilation error, Oracle cannot compile it. Since DDL triggers fire on CREATE/ALTER, you cannot create or alter ANY object in the schema until you fix the trigger. Always test DDL triggers in dev first.
Production Insight
The ORA-04045 trap is real. I've seen a junior DBA deploy a DDL trigger with a typo. They could no longer create indexes, add columns, or even recompile the trigger itself — because each recompilation is a DDL statement that fires the broken trigger.
The fix: connect as SYSDBA and disable the trigger before fixing it.
Rule: always test DDL triggers in a dev schema. Include a fallback path that SYSDBA can use to disable the trigger if it breaks.
Key Takeaway
DDL triggers audit schema changes but can lock you out if they fail.
Always test in dev first. Keep a SYSDBA escape path.
Use them to enforce naming conventions and block dangerous operations.
System Event Triggers — LOGON, LOGOFF, and SERVERERROR
System event triggers fire on database-level events: LOGON, LOGOFF, SERVERERROR, and STARTUP/SHUTDOWN. They are the most underused but most valuable trigger category.
The SERVERERROR trigger is the one I deploy on every production schema. It fires after every Oracle error. When a batch job fails at 3am with a generic error, the SERVERERROR trigger captures the full error stack, the SQL that caused it, and all bind variables — without any application changes.
LOGON triggers enforce security policies: reject connections from untrusted IPs, enforce MFA validation, set session context. But they are dangerous: if your LOGON trigger has an error, nobody can log in to fix it. Always include an exception handler that allows DBA logins.
Resource usage: A SERVERERROR trigger fires on every single error, including harmless ones like ORA-00001 (unique constraint violation) that applications handle. This can flood your error log table. Use a filtering condition (e.g., ORA_SERVER_ERROR(1) IN (list of critical errors)) to reduce noise.
plsql_system_event_triggers.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
-- SERVERERROR trigger: capture every error in productionCREATEORREPLACETRIGGER trg_capture_errors
AFTERSERVERERRORONSCHEMADECLAREPRAGMA AUTONOMOUS_TRANSACTION;
v_sql CLOB;
BEGIN-- Get the SQL that caused the errorBEGINSELECT sql_text INTO v_sql
FROM v$sql
WHERE sql_id = SYS_CONTEXT('USERENV', 'CURRENT_SQL_ID')
ANDROWNUM = 1;
EXCEPTIONWHEN NO_DATA_FOUND THEN v_sql := NULL;
END;
INSERTINTOerror_log (
error_timestamp, ora_login_user,
error_number, error_message,
error_stack, sql_text
) VALUES (
SYSTIMESTAMP,
SYS_CONTEXT('USERENV', 'SESSION_USER'),
ORA_SERVER_ERROR(1),
ORA_SERVER_ERROR_MSG(1),
DBMS_UTILITY.FORMAT_ERROR_STACK,
v_sql
);
COMMIT;
END;
/
-- LOGON trigger with safetyCREATEORREPLACETRIGGER trg_logon_control
AFTERLOGONONSCHEMABEGIN-- Allow DBA users regardless of any checksIF SYS_CONTEXT('USERENV', 'SESSION_USER') IN ('SYS', 'SYSTEM', 'DBA_USER') THENRETURN;
ENDIF;
-- Reject connections from untrusted IP rangesIF SYS_CONTEXT('USERENV', 'IP_ADDRESS') NOTLIKE'192.168.1.%'THEN
RAISE_APPLICATION_ERROR(-20010,
'Access denied from IP: ' || SYS_CONTEXT('USERENV', 'IP_ADDRESS'));
ENDIF;
-- Set session context for auditing
DBMS_APPLICATION_INFO.SET_CLIENT_INFO(SYS_CONTEXT('USERENV', 'MODULE'));
DBMS_SESSION.SET_IDENTIFIER(SYS_CONTEXT('USERENV', 'SESSION_USER'));
EXCEPTIONWHENOTHERSTHEN-- Log the error (autonomous transaction to survive rollback)DECLAREPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTOlogon_errors (username, ip_address, error_msg, attempted_at)
VALUES (SYS_CONTEXT('USERENV', 'SESSION_USER'),
SYS_CONTEXT('USERENV', 'IP_ADDRESS'),
SQLERRM, SYSTIMESTAMP);
COMMIT;
END;
RAISE;
END;
/
Forge Tip: The Most Valuable Trigger You Are Not Using
The SERVERERROR trigger is the one I deploy on every production schema. It captures every Oracle error with full stack and SQL text, at zero application cost. When a batch job fails at 3am, you have the exact error, the SQL that caused it, and the bind variables — all automatically. This one trigger saves days of debugging time annually.
Production Insight
A LOGON trigger without exception handling once locked out the entire dev team. The trigger had a bug, and nobody could log in to fix it because logging in triggered the bug again.
Solution: include a whitelist of DBA usernames that bypass all checks. Also use autonomous transaction logging inside the exception handler.
Rule: never deploy a LOGON trigger without a DBA bypass and an exception handler that logs the error.
Key Takeaway
SERVERERROR triggers capture every Oracle error with context.
LOGON triggers enforce security but need fallback for failures.
Always include DBA bypass and autonomous transaction error logging.
CALL Syntax — The Clean Way to Write Triggers
Oracle 12c introduced CALL syntax, which lets you invoke a stored procedure directly from the trigger definition instead of writing the PL/SQL block inline. This is a significant design improvement.
The old pattern: 200 lines of PL/SQL logic inside the trigger body. Hard to test independently, hard to version-control separately, impossible to call from other contexts.
The new pattern: One procedure per business rule, called from one trigger per event. The procedure is independently testable, version-controllable, and reusable. The trigger just wires the event to the procedure.
Why this matters: When you need to test the logic without firing the trigger, you call the procedure directly. When you need to run the same logic during a data migration, you call the procedure directly. When you need to unit test the business rule, you call the procedure directly.
Trade-off: CALL syntax adds a small overhead because Oracle resolves the procedure call dynamically. For row-level triggers on high-throughput tables, inline PL/SQL might be slightly faster. But the maintainability gain usually outweighs the micro-performance cost.
plsql_call_syntax.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
-- The procedure: independently testableCREATEORREPLACEPACKAGE io_thecodeforge_order_triggers ASPROCEDUREvalidate_order(
p_new_order_date INDATE,
p_new_ship_date INDATE,
p_new_customer_id INNUMBER
);
PROCEDUREaudit_order_change(
p_order_id INNUMBER,
p_old_status INVARCHAR2,
p_new_status INVARCHAR2,
p_changed_by INVARCHAR2
);
END io_thecodeforge_order_triggers;
/
CREATEORREPLACEPACKAGEBODY io_thecodeforge_order_triggers ASPROCEDUREvalidate_order(
p_new_order_date INDATE,
p_new_ship_date INDATE,
p_new_customer_id INNUMBER
) IS
v_customer_exists NUMBER;
BEGINIF p_new_ship_date < p_new_order_date THEN
RAISE_APPLICATION_ERROR(-20040,
'Ship date cannot precede order date');
ENDIF;
SELECTCOUNT(*) INTO v_customer_exists
FROM customers WHERE customer_id = p_new_customer_id;
IF v_customer_exists = 0THEN
RAISE_APPLICATION_ERROR(-20041,
'Customer does not exist: ' || p_new_customer_id);
ENDIF;
END validate_order;
PROCEDUREaudit_order_change(
p_order_id INNUMBER,
p_old_status INVARCHAR2,
p_new_status INVARCHAR2,
p_changed_by INVARCHAR2
) ISBEGININSERTINTOorder_audit (
audit_id, order_id, old_status, new_status,
changed_by, changed_at
) VALUES (
order_audit_seq.NEXTVAL, p_order_id,
p_old_status, p_new_status,
p_changed_by, SYSTIMESTAMP
);
END audit_order_change;
END io_thecodeforge_order_triggers;
/
-- The trigger: just wires the event to the procedureCREATEORREPLACETRIGGER trg_orders_bi
BEFOREINSERTON orders
FOREACHROWCALL io_thecodeforge_order_triggers.validate_order(
:NEW.order_date, :NEW.ship_date, :NEW.customer_id)
/
CREATEORREPLACETRIGGER trg_orders_au
AFTERUPDATEOF status ON orders
FOREACHROWCALL io_thecodeforge_order_triggers.audit_order_change(
:OLD.order_id, :OLD.status, :NEW.status,
SYS_CONTEXT('USERENV', 'SESSION_USER'))
/
-- Now you can test the logic without the triggerBEGIN
io_thecodeforge_order_triggers.validate_order(
SYSDATE, SYSDATE - 1, 999);
END;
/
Output
ORA-20040: Ship date cannot precede order date
Forge Tip: One Procedure Per Rule, One Trigger Per Event
The CALL syntax is the production pattern. One procedure per business rule, called from one trigger per event. The procedure is independently testable — you can call it from a unit test, a data migration script, or a batch job without triggering the DML event. If you are still writing 200-line trigger bodies, switch to CALL syntax.
Production Insight
I've seen teams maintain 500-line trigger bodies that mix validation, auditing, and cross-table updates. Debugging was impossible — when something went wrong, you couldn't reproduce it without firing the DML.
Using CALL syntax, each procedure is independently testable. You can call validate_order from a unit test without any DML side effects. That saves hours of debugging.
Rule: write trigger logic in procedures and call them from the trigger. Never write business logic directly in the trigger body.
Key Takeaway
CALL syntax invokes a stored procedure from the trigger.
The procedure is independently testable and reusable.
One procedure per rule, one trigger per event — clean separation.
Cascading Triggers — The Hardest Production Bug to Diagnose
Cascading triggers occur when trigger A on table X modifies table Y, which fires trigger B on table Y, which modifies table Z, and so on. Oracle has a default limit of 50 trigger cascades before raising an error.
The danger: Infinite loops if trigger A modifies its own table through a stored procedure (bypassing the mutating table check). Silent performance degradation if each trigger adds 1ms and the chain fires 47 times.
The production nightmare: I inherited a system where 8 triggers cascaded across 6 tables. A single UPDATE on the root table triggered 47 trigger executions. The fix was consolidating the logic into 2 compound triggers and removing redundant cross-table triggers.
The design rule: Each trigger should do one thing. If trigger A needs to modify another table, that is fine — but document the dependency chain. If your chain exceeds 3 levels, refactor into stored procedures.
The debugging technique: Before deploying cascading triggers, map the dependency chain. Query USER_TRIGGERS for every table involved and trace which trigger modifies which table. Use USER_DEPENDENCIES to find what each trigger references.
plsql_cascading_triggers.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
-- BAD: Cascading triggers that are hard to debug-- Trigger on orders updates inventoryCREATEORREPLACETRIGGER trg_orders_ai
AFTERINSERTON orders
FOREACHROWBEGINUPDATE inventory
SET quantity = quantity - :NEW.quantity
WHERE product_id = :NEW.product_id;
END;
/
-- Trigger on inventory writes to stock_logCREATEORREPLACETRIGGER trg_inventory_au
AFTERUPDATEON inventory
FOREACHROWBEGININSERTINTOstock_log (product_id, old_qty, new_qty, log_time)
VALUES (:OLD.product_id, :OLD.quantity, :NEW.quantity, SYSTIMESTAMP);
END;
/
-- Trigger on stock_log sends alert if quantity is lowCREATEORREPLACETRIGGER trg_stock_log_ai
AFTERINSERTON stock_log
FOREACHROWBEGINIF :NEW.new_qty < 10THENINSERTINTOalerts (alert_type, message, created_at)
VALUES ('LOW_STOCK',
'Product ' || :NEW.product_id || ' has only ' ||
:NEW.new_qty || ' units left', SYSTIMESTAMP);
ENDIF;
END;
/
-- Map the dependency chain before deployingSELECT t.table_name, t.trigger_name, t.triggering_event, t.status
FROM user_triggers t
WHERE t.table_name IN ('ORDERS', 'INVENTORY', 'STOCK_LOG', 'ALERTS')
ORDERBY t.table_name, t.trigger_name;
-- Find what a trigger modifies (dependency chain)SELECT d.referenced_name, d.referenced_type
FROM user_dependencies d
WHERE d.name = 'TRG_ORDERS_AI'AND d.type = 'TRIGGER';
Output
TABLE_NAME TRIGGER_NAME TRIGGERING_EVENT STATUS
INVENTORY TRG_INVENTORY_AU UPDATE ENABLED
ORDERS TRG_ORDERS_AI INSERT ENABLED
STOCK_LOG TRG_STOCK_LOG_AI INSERT ENABLED
Forge Warning: Trace Your Trigger Chains
Map every trigger dependency chain before deploying to production. If trigger A fires trigger B which fires trigger C, document it. I inherited a system where 8 triggers cascaded across 6 tables — a single UPDATE triggered 47 trigger executions. The fix was consolidating into 2 compound triggers. If your trigger chain exceeds 3 levels, refactor.
Production Insight
Cascading triggers are invisible until they cause a production outage. They don't show up in standard monitoring. The only way to find them is to trace the dependency chain.
I once spent an afternoon tracing a chain by looking at each trigger's body to see which tables it modified. Use the user_dependencies view to automate this.
Rule: automatically generate a trigger dependency graph as part of your CI/CD pipeline. Reject deployments that create chains longer than 3 levels.
Key Takeaway
Cascading triggers chain across tables and are hard to debug.
Keep chains shallow (≤3 levels). Use compound triggers to consolidate.
Document every cross-table trigger dependency.
Autonomous Transactions in Triggers — Logging That Survives Rollback
An autonomous transaction (PRAGMA AUTONOMOUS_TRANSACTION) creates a separate transaction within the trigger that can COMMIT independently of the triggering transaction.
The legitimate use case: Error logging. If you want error records to survive even when the triggering transaction rolls back, use an autonomous transaction for the error INSERT. SERVERERROR triggers and connection logging are the standard autonomous transaction use cases.
The danger: Autonomous transactions cannot see uncommitted changes from the triggering transaction. If you query the triggering table in the autonomous transaction, you see the committed state, not the mid-DML state.
The phantom record problem: If you use autonomous transactions for successful-operation audit triggers, you get phantom records — rows in the audit table whose triggering DML was rolled back. The audit trail says the operation happened, but the data says it did not.
Another risk: Autonomous transactions can cause deadlocks if they try to modify tables also being modified by the main transaction. Because they run in a separate session, two autonomous transactions on the same session can block each other.
plsql_autonomous_triggers.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
-- GOOD: Error logging with autonomous transactionCREATEORREPLACETRIGGER trg_server_error
AFTERSERVERERRORONSCHEMADECLAREPRAGMA AUTONOMOUS_TRANSACTION;
v_sql CLOB;
v_stack CLOB;
BEGIN
v_stack := DBMS_UTILITY.FORMAT_ERROR_STACK;
BEGINSELECT sql_text INTO v_sql
FROM v$sql
WHERE sql_id = SYS_CONTEXT('USERENV', 'CURRENT_SQL_ID')
ANDROWNUM = 1;
EXCEPTIONWHEN NO_DATA_FOUND THEN v_sql := NULL;
END;
INSERTINTOerror_log (
ora_login_user, error_number, error_message,
error_stack, sql_text
) VALUES (
SYS_CONTEXT('USERENV', 'SESSION_USER'),
ORA_SERVER_ERROR(1),
ORA_SERVER_ERROR_MSG(1),
v_stack, v_sql
);
COMMIT;
END;
/
-- GOOD: Connection log that survives logout failureCREATEORREPLACETRIGGER trg_logon_audit
AFTERLOGONONSCHEMADECLAREPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTOconnection_log (
username, logon_time, os_user, machine
) VALUES (
SYS_CONTEXT('USERENV', 'SESSION_USER'),
SYSTIMESTAMP,
SYS_CONTEXT('USERENV', 'OS_USER'),
SYS_CONTEXT('USERENV', 'HOST')
);
COMMIT;
END;
/
-- BAD: Don't use autonomous transactions for normal audit-- This creates phantom records when the triggering transaction rolls backCREATEORREPLACETRIGGER trg_bad_autonomous_audit
AFTERUPDATEON employees
FOREACHROWDECLAREPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTOemployees_audit (...)
VALUES (...);
COMMIT;
END;
/
Forge Warning: Phantom Audit Records
If you use autonomous transactions for successful-operation audit triggers, you get phantom records — rows in the audit table whose triggering DML was rolled back. The audit trail says the operation happened, but the data says it did not. Use autonomous transactions only for error logging and SERVERERROR triggers, not for normal audit triggers.
Production Insight
Phantom audit records are a data integrity nightmare. A team once used autonomous transactions for all audit triggers. When a batch job failed mid-way, the audit table showed 50% of the changes as 'applied', but the actual data was rolled back to 0%. Reconciliation took a week.
The fix: don't use autonomous transactions for success audit. Use normal triggers that are part of the main transaction. If they fail, the DML rolls back, and you get no phantom records.
Rule: autonomous transactions are for error logging only. Never for success auditing.
Key Takeaway
Autonomous transactions COMMIT independently of the main transaction.
Use them only for error logging that must survive rollback.
Normal audit triggers should NOT be autonomous — they create phantom records.
TRUNCATE vs DELETE — The Audit Bypass You Did Not Know About
TRUNCATE is DDL, not DML. DML triggers do NOT fire on TRUNCATE. This is one of the most common trigger misconceptions, and it has real security implications.
DELETE: Fires DELETE triggers (row-level and statement-level). Logged in redo logs. Can be rolled back. TRUNCATE: Does NOT fire any DML triggers. Minimal redo logging. Cannot be rolled back (implicit COMMIT). Much faster than DELETE.
The security implication: If your audit trigger is designed to catch all deletions, TRUNCATE bypasses it completely. A DBA can TRUNCATE a table and no audit record is created.
The production story: A team had a trigger-based audit system that logged every DELETE. During a performance investigation, a DBA ran TRUNCATE TABLE orders to clear test data. The audit log showed nothing. The DBA thought the table was empty. It was not — it was production data. The TRUNCATE was fast, silent, and irreversible.
Defence: Use a DDL trigger to catch TRUNCATE events and either log them or block them entirely. For tables that require audit, consider using FLASHBACK TABLE instead of TRUNCATE for removal.
plsql_truncate_vs_delete.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- Demonstrate: DELETE fires triggers, TRUNCATE does notCREATEORREPLACETRIGGER trg_test_delete
AFTERDELETEON test_data
FOREACHROWBEGININSERTINTOtest_audit (operation, row_data)
VALUES ('DELETE', 'ID=' || :OLD.id);
END;
/
-- DELETE fires the triggerDELETEFROM test_data WHERE id = 1;
SELECT * FROM test_audit;
-- Shows: DELETE, ID=1-- TRUNCATE does NOT fire the triggerTRUNCATETABLE test_data;
SELECT * FROM test_audit;
-- No new audit records — TRUNCATE bypassed the trigger entirely-- Solution: DDL trigger to catch TRUNCATECREATEORREPLACETRIGGER trg_prevent_truncate
BEFORETRUNCATEONSCHEMABEGINIF ORA_DICT_OBJ_TYPE = 'TABLE'THENINSERTINTOschema_change_log (
ora_login_user, ora_sysevent,
ora_dict_obj_name, ora_dict_obj_type
) VALUES (
SYS_CONTEXT('USERENV', 'SESSION_USER'),
ORA_SYSEVENT,
ORA_DICT_OBJ_NAME,
ORA_DICT_OBJ_TYPE
);
IF SYS_CONTEXT('USERENV', 'CLIENT_INFO') != 'ALLOW_TRUNCATE'THEN
RAISE_APPLICATION_ERROR(-20050,
'TRUNCATE blocked on ' || ORA_DICT_OBJ_NAME ||
'. Use DELETE or set ALLOW_TRUNCATE context.');
ENDIF;
ENDIF;
END;
/
Forge Warning: TRUNCATE Bypasses All DML Triggers
TRUNCATE is DDL. No DML trigger fires. If your audit system relies on AFTER DELETE triggers, TRUNCATE is invisible to it. Use a DDL trigger to catch or block TRUNCATE on audited tables. I have seen a DBA truncate a production table with no audit trail because nobody knew TRUNCATE bypasses DML triggers.
Production Insight
TRUNCATE bypassing triggers is a compliance gap. Many auditors assume that any deletion is logged, but TRUNCATE leaves no trace in conventional audit tables.
The fix: deploy a schema-level DDL trigger that captures all TRUNCATE events. Even better, block TRUNCATE on production tables and force DELETE with row-level audit.
Rule: if compliance requires logging every deletion, use a DDL trigger to block TRUNCATE or log it in a separate table.
Key Takeaway
TRUNCATE is DDL and does not fire DML triggers.
Use a DDL trigger to catch or block TRUNCATE on audited tables.
DELETE is slower but auditable; TRUNCATE is fast and invisible.
FOLLOWS and PRECEDES — Explicit Trigger Ordering
Oracle 11g+ introduced FOLLOWS and PRECEDES clauses to control trigger execution order when multiple triggers exist on the same event. Before this, execution order was alphabetical by trigger name — fragile and undocumented.
FOLLOWS: This trigger fires after the specified trigger. PRECEDES: This trigger fires before the specified trigger.
When to use: When you genuinely need multiple triggers on the same event and one depends on the other side effects. For example, trigger A defaults a value, trigger B validates it — B must FOLLOWS A.
When NOT to use: If you can consolidate the logic into a single trigger, do that instead. Multiple triggers on the same event are inherently harder to reason about. FOLLOWS makes the dependency explicit, but a single trigger eliminates the dependency entirely.
Another pitfall: If you specify FOLLOWS a trigger that does not exist, Oracle raises ORA-25005. Always verify the referenced trigger name exists and is on the same table and event combination.
plsql_trigger_follows.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
-- Trigger A: set defaultsCREATEORREPLACETRIGGER trg_orders_defaults
BEFOREINSERTON orders
FOREACHROWBEGINIF :NEW.order_date ISNULLTHEN
:NEW.order_date := SYSDATE;
ENDIF;
IF :NEW.status ISNULLTHEN
:NEW.status := 'PENDING';
ENDIF;
END;
/
-- Trigger B: validate (must run AFTER defaults are set)CREATEORREPLACETRIGGER trg_orders_validate
BEFOREINSERTON orders
FOREACHROWFOLLOWS trg_orders_defaults
BEGINIF :NEW.ship_date < :NEW.order_date THEN
RAISE_APPLICATION_ERROR(-20060,
'Ship date cannot precede order date');
ENDIF;
IF :NEW.status NOTIN ('PENDING', 'CONFIRMED', 'SHIPPED') THEN
RAISE_APPLICATION_ERROR(-20061,
'Invalid status: ' || :NEW.status);
ENDIF;
END;
/
-- View the orderingSELECT trigger_name, trigger_type, triggering_event, status
FROM user_triggers
WHERE table_name = 'ORDERS'ORDERBY trigger_name;
Output
TRIGGER_NAME TRIGGER_TYPE TRIGGERING_EVENT STATUS
TRG_ORDERS_DEFAULTS BEFORE EACH ROW INSERT ENABLED
TRG_ORDERS_VALIDATE BEFORE EACH ROW INSERT ENABLED
Production Insight
Before FOLLOWS, trigger ordering was alphabetically by name. Renaming a trigger could change the order silently. I've seen a trigger named 'z_last' suddenly fire before 'a_first' after a rename that made 'z_last' become 'zz_last'.
Now, use FOLLOWS to make dependencies explicit. But still prefer consolidating into one trigger.
Rule: if you must have multiple triggers on the same event, use FOLLOWS. Better yet, combine them into a single compound trigger.
Key Takeaway
FOLLOWS/PRECEDES control trigger execution order explicitly.
Don't rely on alphabetical ordering — it breaks silently.
Consolidate multiple triggers into one when possible.
Error Handling in Triggers — RAISE_APPLICATION_ERROR and the Rollback Effect
When a trigger raises an exception, the entire triggering DML is rolled back — not just the last row, but the entire statement. If you INSERT 1000 rows and row 500 triggers an exception, all 1000 inserts are rolled back.
The audit implication: If your trigger inserts an audit record before raising an exception, that audit INSERT is also rolled back (unless it uses an autonomous transaction). You lose the record of the failed attempt.
RAISE vs RAISE_APPLICATION_ERROR: RAISE re-raises the current exception (useful in EXCEPTION blocks). RAISE_APPLICATION_ERROR creates a custom error with your message. Use RAISE_APPLICATION_ERROR for business rule violations with meaningful messages.
ORA-04092: The error you get if you COMMIT or ROLLBACK inside a trigger. Triggers execute within the triggering transaction — you cannot control the transaction boundary.
Custom error codes: Use the range -20000 to -20999 for RAISE_APPLICATION_ERROR. Choose codes consistently per module (e.g., -20xxx for order triggers, -21xxx for inventory). This makes log analysis easier.
plsql_trigger_errors.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
-- Proper error handling in a triggerCREATEORREPLACETRIGGER trg_validate_salary
BEFOREINSERTORUPDATEOF salary ON employees
FOREACHROWBEGINIF :NEW.salary <= 0THEN
RAISE_APPLICATION_ERROR(-20070,
'Salary must be positive. Received: ' || :NEW.salary ||
' for employee ' || :NEW.employee_id);
ENDIF;
IF :NEW.salary > 500000THEN
RAISE_APPLICATION_ERROR(-20071,
'Salary exceeds maximum allowed (500000). Received: ' || :NEW.salary);
ENDIF;
END;
/
-- Re-raising exceptions with error loggingCREATEORREPLACETRIGGER trg_orders_with_logging
BEFOREINSERTON orders
FOREACHROWBEGINIF :NEW.ship_date < :NEW.order_date THEN
RAISE_APPLICATION_ERROR(-20072,
'Ship date cannot precede order date');
ENDIF;
EXCEPTIONWHENOTHERSTHENDECLAREPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTOerror_log (
error_number, error_message, error_stack
) VALUES (
SQLCODE, SQLERRM,
DBMS_UTILITY.FORMAT_ERROR_STACK
);
COMMIT;
END;
RAISE;
END;
/
Production Insight
The rollback effect of trigger errors is often overlooked. A validation trigger that fails on row 500 loses all earlier valid insertions. That's expensive for bulk operations.
The fix: if you need to allow partial success, don't use triggers for validation. Use application-level checks that report all errors at once.
Rule: triggers are atomic — they either succeed completely or roll back everything. For bulk operations, validate at the application layer first.
Key Takeaway
Trigger failure rolls back the entire DML statement.
Use RAISE_APPLICATION_ERROR for clear business rule violations.
Use autonomous transactions to log failures that survive rollback.
● Production incidentPOST-MORTEMseverity: high
The 47-Trigger Cascade That Brought Down a Payment Batch
Symptom
A nightly batch UPDATE on a 50,000-row orders table started timing out at 30 minutes. The previous night it completed in 2 minutes. No code changes were made.
Assumption
The team assumed a database or hardware issue. They checked CPU, I/O, and wait events but found no obvious bottleneck.
Root cause
A newly deployed trigger on the inventory table was cascading to stock_log, which then fired an alert trigger. That alert trigger updated the products table, which had its own triggers. The chain of 8 triggers was undocumented and hidden. The single UPDATE caused 47 trigger executions per row, generating 2.35 million trigger invocations for 50,000 rows.
Fix
Disabled all cascading triggers via ALTER TABLE ... DISABLE ALL TRIGGERS. Consolidated the logic into two compound triggers. Added a trigger dependency map in the documentation. Reduced the total trigger executions to 3 per row (one row-level audit, one statement-level update, one cross-table sync).
Key lesson
Map every trigger dependency chain before deploying to production. Keep chains shallow (max 3 levels).
Use compound triggers to consolidate multiple operations on the same table into a single trigger body.
Document every cross-table trigger modification. Include the table being modified and the affected triggers.
Add a trigger execution counter (via autonomous transaction log) during regression testing to detect unexpected cascades.
Production debug guideSymptom → Action grid for the most common trigger failures5 entries
Symptom · 01
DML statement completes but audit table is empty
→
Fix
Check if the trigger is DISABLED (SELECT status FROM user_triggers WHERE trigger_name = '...'). If ENABLED, check for unhandled exceptions — query user_errors for the trigger. If trigger uses autonomous transaction for audit, verify COMMIT is present.
Symptom · 02
ORA-04091: mutating table error
→
Fix
Confirm a row-level trigger is querying its own table. Fix with a compound trigger: collect row IDs in AFTER EACH ROW, then run the query in AFTER STATEMENT. Alternatively, restructure the logic into a stored procedure called from the application.
Symptom · 03
ORA-04092: cannot COMMIT or ROLLBACK in a trigger
→
Fix
Remove any COMMIT or ROLLBACK from the trigger body. If you need to log errors that survive a rollback, use PRAGMA AUTONOMOUS_TRANSACTION in a separate procedure.
Symptom · 04
Trigger fires but no rows are affected (UPDATE/DELETE with WHEN clause)
→
Fix
Verify the WHEN clause evaluates to TRUE. Remember that WHEN uses pseudorecords without colon (NEW.salary, not :NEW.salary). Also check that column references in WHEN are valid.
Symptom · 05
DDL triggers prevent any schema changes (ORA-04045)
→
Fix
Connect as SYSDBA and disable the DDL trigger: ALTER TRIGGER trg_ddl_audit DISABLE;. Fix the trigger compilation error, then re-enable. Always test DDL triggers in dev first.
★ Quick Debug Cheat Sheet for PL/SQL TriggersThree commands to diagnose any trigger issue in under 60 seconds.
DML fails with ORA- error but no clue which trigger−
Immediate action
Find all triggers on the table and their status
Commands
SELECT trigger_name, trigger_type, triggering_event, status FROM user_triggers WHERE table_name = '<TABLE_NAME>';
ALTER TABLE <TABLE_NAME> DISABLE ALL TRIGGERS; -- re-run DML to isolate
Fix now
If DML works after disabling all triggers, re-enable one at a time: ALTER TRIGGER <TRIGGER_NAME> ENABLE; test each until you find the culprit.
Audit records missing for some operations+
Immediate action
Check if the trigger uses autonomous transaction and whether COMMIT is called
Commands
SELECT text FROM user_source WHERE name = '<TRIGGER_NAME>' AND type = 'TRIGGER' ORDER BY line;
Check for PRAGMA AUTONOMOUS_TRANSACTION and subsequent COMMIT. Also check for WHEN clause that might filter rows.
Fix now
If autonomous transaction is missing and audit records roll back, refactor with PRAGMA AUTONOMOUS_TRANSACTION. If WHEN clause is too restrictive, adjust the condition.
Bulk load takes forever (row-level trigger on millions of rows)+
Immediate action
Disable triggers before the load, enable after
Commands
ALTER TABLE <TABLE_NAME> DISABLE ALL TRIGGERS; -- run bulk DML then re-enable
ALTER TABLE <TABLE_NAME> ENABLE ALL TRIGGERS; -- after load
Fix now
If you cannot disable triggers, batch the DML in smaller transactions and use row limits. Consider switching to compound triggers or statement-level triggers where possible.
Trigger Type Comparison
Type
Fires
Can Modify :NEW
Use Case
Performance Impact
BEFORE row-level
Before each row DML
Yes
Default values, validation, data transformation
Context switch per row – expensive for bulk
AFTER row-level
After each row DML
No
Auditing, cross-table sync per row
Same as BEFORE – 1 context switch per row
BEFORE statement-level
Once before statement
No (no :NEW)
Pre-statement checks, set session context
Negligible – fires once per statement
AFTER statement-level
Once after statement
No (no :NEW)
Statement-level audit, summary updates
Negligible – fires once per statement
INSTEAD OF (view)
In place of DML on view
Read-only
Making join/agg views updatable
Depends on logic inside trigger
DDL (schema-level)
On CREATE/ALTER/DROP
N/A
Schema audit, naming enforcement
Adds latency to every DDL command
System event (LOGON, SERVERERROR)
On specific DB event
N/A
Security, error capture
Fires per event – filter SERVERERROR to avoid noise
Key takeaways
1
Triggers fire automatically within the triggering transaction; failure rolls back the entire DML.
2
BEFORE row-level triggers can modify :NEW; AFTER triggers cannot.
3
Compound triggers are the correct fix for the mutating table error (ORA-04091).
4
CALL syntax makes trigger logic testable by moving it to stored procedures.
5
Autonomous transactions are for error logging only
never for success auditing (phantom records).
6
TRUNCATE bypasses all DML triggers
use a DDL trigger to catch or block it.
7
Map and document all cascading trigger chains; keep them shallow (≤3 levels).
Common mistakes to avoid
5 patterns
×
Using autonomous transactions for success auditing
Symptom
Audit records appear for operations that were rolled back – phantom records. Audit trail says change happened but data shows it did not.
Fix
Use autonomous transactions only for error logging. Normal audit triggers should be part of the main transaction so they roll back together.
×
Forgetting to disable triggers during bulk loads
Symptom
Batch jobs that normally take minutes start timing out. A row-level trigger fires millions of times unnecessarily.
Fix
Disable all triggers on the target table before the load, then re-enable. ALTER TABLE table_name DISABLE ALL TRIGGERS;
×
Not providing a DBA bypass in LOGON triggers
Symptom
A buggy LOGON trigger prevents all users from logging in, including administrators. No way to fix without a database restart or SYSDBA connection.
Fix
In the LOGON trigger, check for DBA users (SYS, SYSTEM, custom DBA account) and RETURN immediately. Also add an exception handler with autonomous transaction logging.
×
Using :NEW in statement-level triggers
Symptom
ORA-04082: NEW or OLD references not allowed in statement-level triggers. Statement-level triggers don't have per-row data access.
Fix
Use row-level triggers (FOR EACH ROW) if you need :NEW/:OLD. For statement-level actions like logging that a statement executed, remove all pseudorecord references.
×
Creating INSTEAD OF triggers for only one DML type
Symptom
Users can INSERT through a view but get ORA-01779 when trying to UPDATE or DELETE. The view has INSTEAD OF triggers only for INSERT.
Fix
Create INSTEAD OF triggers for all DML operations the application will use on that view: INSERT, UPDATE, DELETE. Each requires its own trigger.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What is the difference between a row-level and a statement-level trigger...
Q02SENIOR
Explain the mutating table error (ORA-04091). How do you fix it?
Q03SENIOR
What are autonomous transactions in triggers? What are the risks?
Q01 of 03SENIOR
What is the difference between a row-level and a statement-level trigger? When would you use each?
ANSWER
A row-level trigger (FOR EACH ROW) fires once per row affected by the DML statement. A statement-level trigger fires once per statement regardless of row count. Use row-level for per-row logic like auditing each changed value, updating derived columns per row, or enforcing rules that depend on :NEW/:OLD data. Use statement-level for actions that are independent of row count, such as logging that a table was updated, setting session context, or running a summary aggregation after the entire statement completes. Performance is critical: a row-level trigger on a bulk UPDATE of 1 million rows fires 1 million times, while a statement-level trigger fires once.
Q02 of 03SENIOR
Explain the mutating table error (ORA-04091). How do you fix it?
ANSWER
ORA-04091 occurs when a row-level trigger queries or modifies the same table that fired it. Oracle raises this error because the table is in an inconsistent state during the DML. The recommended fix is a compound trigger (Oracle 11g+): use the AFTER EACH ROW section to collect row IDs into a collection, then query the table in the AFTER STATEMENT section when the DML is complete and the table is stable. Do not use autonomous transactions to work around this – they cannot see uncommitted changes and create data integrity issues.
Q03 of 03SENIOR
What are autonomous transactions in triggers? What are the risks?
ANSWER
PRAGMA AUTONOMOUS_TRANSACTION allows a trigger to execute a separate transaction that can COMMIT independently of the main transaction. The legitimate use case is error logging – you want the error record to survive even if the main transaction rolls back. The biggest risk is phantom audit records: if you use autonomous transactions for success auditing, the audit log shows changes that were later rolled back, creating a false audit trail. Also, autonomous transactions cannot see uncommitted changes from the parent transaction and can cause deadlocks if they modify tables also touched by the main transaction.
01
What is the difference between a row-level and a statement-level trigger? When would you use each?
SENIOR
02
Explain the mutating table error (ORA-04091). How do you fix it?
SENIOR
03
What are autonomous transactions in triggers? What are the risks?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Can a trigger be disabled without dropping it?
Yes, use ALTER TRIGGER trigger_name DISABLE; or ALTER TABLE table_name DISABLE ALL TRIGGERS; to disable all triggers on a table. Re-enable with ENABLE.
Was this helpful?
02
What happens if a trigger has a compilation error?
The trigger becomes invalid. If it's a DDL trigger, ORA-04045 may prevent any DDL on the schema. Use SHOW ERRORS TRIGGER trigger_name to diagnose and recompile with ALTER TRIGGER trigger_name COMPILE;.
Was this helpful?
03
Can I have multiple triggers on the same table and event?
Yes. Without FOLLOWS/PRECEDES, execution order is alphabetical by trigger name. Oracle 11g+ allows ordering with FOLLOWS. Prefer consolidating into a single compound trigger.
Was this helpful?
04
Do INSTEAD OF triggers work on materialised views?
No, INSTEAD OF triggers only work on non-editionable views. Materialised views are not directly updatable; refresh mechanisms handle changes.
Was this helpful?
05
Why does my SERVERERROR trigger cause performance issues?
It fires on every error, including trivial ones. Reduce noise by filtering error numbers: e.g., IF ORA_SERVER_ERROR(1) IN (-1, -942, -1403) THEN ...