Core problem: Oracle's rich error stack (ORA-06512 with automatic line numbers and call chain) has no direct 1:1 equivalent in PostgreSQL — you must build that context manually
Key component 1: PostgreSQL uses five-character SQLSTATE codes and GET STACKED DIAGNOSTICS for context capture — this is your replacement for DBMS_UTILITY.FORMAT_ERROR_BACKTRACE
Key component 2: Most named Oracle exceptions map cleanly to PostgreSQL exception names (NO_DATA_FOUND, TOO_MANY_ROWS), but integrity constraint errors diverge — DUP_VAL_ON_INDEX becomes unique_violation (SQLSTATE 23505)
Key component 3: Oracle autonomous transactions (PRAGMA AUTONOMOUS_TRANSACTION) have no native PostgreSQL equivalent — you need dblink or pg_background for the same isolation pattern
Performance insight: PostgreSQL EXCEPTION blocks carry genuine overhead — roughly 2ms per exception due to context switching — excessive exception handling in tight loops can degrade batch throughput by 15 to 30 percent
Production insight: Missing error context in PostgreSQL logs is the number one cause of failed post-mortem debugging after migration — instrument before you go live, not after the first incident
Biggest mistake: Carrying over Oracle's WHEN OTHERS THEN NULL pattern verbatim to PostgreSQL — in Oracle it was bad practice; in PostgreSQL it silently swallows errors that would have surfaced in ORA-06512 stacks, and nothing in the logs tells you anything went wrong
Plain-English First
Moving PL/SQL code from Oracle to PostgreSQL is like translating between two languages that share grammar but have completely different vocabularies for expressing problems. Oracle gives you a detailed error receipt — ORA-06512 tells you the exact procedure, the exact line, and the full call chain automatically. PostgreSQL does not print that receipt automatically — you have to ask for it explicitly using GET STACKED DIAGNOSTICS inside every exception block. Beyond that, many of the error 'words' are different: Oracle says DUP_VAL_ON_INDEX, PostgreSQL says unique_violation. Oracle says ORA-01403 for no data found, PostgreSQL says NOT FOUND or NO_DATA_FOUND. And some Oracle features — like autonomous transactions for writing to an audit log even when the main transaction rolls back — simply do not exist natively in PostgreSQL and require workarounds. This guide maps the most common Oracle errors to their PostgreSQL equivalents and shows how to preserve debugging information so that when something fails in production, you are not debugging blind.
Migrating PL/SQL error handling to PL/pgSQL is one of the most common sources of production defects in database migration projects — and consistently the most underestimated. The ORA-06512 stack trace, which Oracle generates automatically and which developers rely on to pinpoint failures without touching source code, has no direct equivalent in PostgreSQL. PostgreSQL generates SQLSTATE codes and provides the GET STACKED DIAGNOSTICS mechanism, but capturing that context requires explicit instrumentation in every exception block. Teams that migrate the business logic correctly but skip the error handling instrumentation end up with a system that runs but fails invisibly.
The ORA-06512 problem compounds because Oracle's error stacks are part of the developer mental model. Engineers read 'ORA-06512: at SCHEMA.PACKAGE, line 342' and immediately know where to look. In PostgreSQL, without GET STACKED DIAGNOSTICS, the same failure surfaces as a terse error message with no location, no call chain, and no indication of what data triggered the failure. Adding error context after a production incident is far more expensive than instrumenting before deployment.
Beyond context capture, the migration has three other dimensions that require careful engineering: error code mapping (Oracle's named exceptions and ORA codes do not map numerically to PostgreSQL SQLSTATE codes), transaction control semantics (Oracle's implicit transaction model differs from PostgreSQL's explicit one in ways that produce subtle data integrity issues), and performance characteristics (PostgreSQL EXCEPTION blocks are more expensive than Oracle's, which matters in batch processing loops). This guide covers all four dimensions with production-ready patterns, concrete error mappings, and the anti-patterns that cause the most damage in the first weeks after go-live.
Oracle to PostgreSQL Error Code Mapping
Oracle's ORA- error numbers have no direct numeric equivalents in PostgreSQL. PostgreSQL uses the SQL standard's five-character SQLSTATE codes, organized into two-character class codes (23 for integrity constraint violations, 42 for syntax errors, P0 for PL/pgSQL-specific errors) followed by three-character subclass codes. The translation requires understanding the semantic intent of each Oracle error, not just its number.
Some mappings are clean and symmetric: NO_DATA_FOUND maps directly to NO_DATA_FOUND in PostgreSQL (both are the named exception for a SELECT INTO that returns zero rows). TOO_MANY_ROWS is identical on both sides. These are part of the SQL standard exception name set.
Other mappings require a context shift: Oracle's DUP_VAL_ON_INDEX maps to PostgreSQL's unique_violation (SQLSTATE 23505). Oracle's VALUE_ERROR (ORA-06502, raised on type conversion failures or value too large for target) maps to various PostgreSQL exceptions depending on the specific cause — numeric_value_out_of_range (SQLSTATE 22003) for overflow, invalid_text_representation (SQLSTATE 22P02) for failed casts, or string_data_right_truncation (SQLSTATE 22001) for values exceeding column length.
The most significant gap is ORA-06512 itself. Oracle generates this automatically as a call stack frame — it requires no developer action. PostgreSQL has no equivalent automatic mechanism. The PG_EXCEPTION_CONTEXT field available via GET STACKED DIAGNOSTICS provides function names in the call chain but does not include PL/pgSQL line numbers within a function. This gap must be bridged by instrumentation.
-- ============================================================-- COMPLETE ORACLE TO POSTGRESQL EXCEPTION NAME MAPPING-- ============================================================-- GROUP 1: Direct name equivalents (same name, both databases)-- Oracle: NO_DATA_FOUND -> PostgreSQL: NO_DATA_FOUND-- Oracle: TOO_MANY_ROWS -> PostgreSQL: TOO_MANY_ROWS-- Oracle: ZERO_DIVIDE -> PostgreSQL: division_by_zero-- Oracle: CURSOR_ALREADY_OPEN -> PostgreSQL: No equivalent (cursors work differently)-- GROUP 2: Name changes, same semantics-- Oracle: DUP_VAL_ON_INDEX -> PostgreSQL: unique_violation (SQLSTATE 23505)-- Oracle: VALUE_ERROR -> PostgreSQL: varies:-- Overflow: numeric_value_out_of_range (SQLSTATE 22003)-- Cast failure: invalid_text_representation (SQLSTATE 22P02)-- String too long: string_data_right_truncation (SQLSTATE 22001)-- Oracle: INVALID_NUMBER -> PostgreSQL: invalid_text_representation (SQLSTATE 22P02)-- Oracle: NOT_LOGGED_ON -> PostgreSQL: connection_failure (SQLSTATE 08006)-- GROUP 3: Oracle exceptions with no direct PostgreSQL equivalent-- Oracle: ORA-06512 (call stack frame) -> Must use GET STACKED DIAGNOSTICS-- Oracle: PRAGMA AUTONOMOUS_TRANSACTION -> dblink or pg_background extension-- Oracle: RAISE_APPLICATION_ERROR -> RAISE EXCEPTION with SQLSTATE 'P0001'-- ============================================================-- EXCEPTION HANDLER TRANSLATION EXAMPLES-- ============================================================-- ORACLE:CREATEORREPLACEPROCEDUREprocess_payment(p_account_id NUMBER) ASBEGINUPDATE accounts SET balance = balance - 100WHERE account_id = p_account_id;
IFSQL%ROWCOUNT = 0THENRAISE NO_DATA_FOUND;
ENDIF;
EXCEPTIONWHEN NO_DATA_FOUND THEN
RAISE_APPLICATION_ERROR(-20001,
'Account not found: account_id=' || p_account_id);
WHEN DUP_VAL_ON_INDEX THEN
RAISE_APPLICATION_ERROR(-20002,
'Duplicate payment detected: account_id=' || p_account_id);
WHENOTHERSTHEN
RAISE_APPLICATION_ERROR(-20099,
'Unexpected error: ' || SQLERRM, TRUE);
END;
/
-- POSTGRESQL EQUIVALENT:CREATEORREPLACEFUNCTION io.thecodeforge.process_payment(
p_account_id INTEGER
) RETURNSVOIDAS $$
DECLARE
v_affected INTEGER;
v_sqlstate TEXT;
v_message TEXT;
v_detail TEXT;
v_context TEXT;
v_constraint TEXT;
BEGINUPDATE accounts
SET balance = balance - 100WHERE account_id = p_account_id;
GETDIAGNOSTICS v_affected = ROW_COUNT;
IF v_affected = 0THENRAISEEXCEPTION'Account not found: account_id=%', p_account_id
USINGERRCODE = 'P0002'; -- no_data_found in the user rangeENDIF;
EXCEPTIONWHEN no_data_found THEN-- Raised above or by a SELECT INTO that returned no rowsRAISEEXCEPTION'Account not found: account_id=%', p_account_id
USINGERRCODE = 'P0001';
WHEN unique_violation THEN-- PostgreSQL equivalent of Oracle's DUP_VAL_ON_INDEXGETSTACKEDDIAGNOSTICS
v_constraint = CONSTRAINT_NAME,
v_detail = PG_EXCEPTION_DETAIL;
RAISEEXCEPTION'Duplicate payment detected: account_id=%, constraint=%',
p_account_id, v_constraint
USINGERRCODE = 'P0001',
DETAIL = v_detail;
WHENOTHERSTHEN-- Catch-all: capture full context before re-raisingGETSTACKEDDIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_detail = PG_EXCEPTION_DETAIL,
v_context = PG_EXCEPTION_CONTEXT;
-- Log to error table for post-mortemINSERTINTO io.thecodeforge.error_log
(procedure_name, parameters, sqlstate, message, detail, context, logged_at)
VALUES
('process_payment', 'account_id=' || p_account_id,
v_sqlstate, v_message, v_detail, v_context, NOW());
RAISEEXCEPTION'Payment processing failed: account_id=%, error=%',
p_account_id, v_message
USINGERRCODE = v_sqlstate,
DETAIL = v_context;
END;
$$ LANGUAGE plpgsql;
Error Translation Mindset
PostgreSQL exceptions map to SQLSTATE class codes: Class 23 = integrity constraint violations, Class 22 = data exceptions, Class 08 = connection exceptions, Class P0 = PL/pgSQL-specific
Named exceptions (NO_DATA_FOUND, TOO_MANY_ROWS) exist in both databases and are the safest migration targets — prefer them over SQLSTATE codes for readability
Oracle's DUP_VAL_ON_INDEX becomes unique_violation (SQLSTATE 23505) — capture CONSTRAINT_NAME via GET STACKED DIAGNOSTICS to know which constraint was violated
Custom application errors (RAISE_APPLICATION_ERROR in Oracle) become RAISE EXCEPTION with SQLSTATE 'P0001' in PostgreSQL — the -20001 to -20999 range does not exist
ORA-06512 has no equivalent — instrument GET STACKED DIAGNOSTICS in every exception block before deployment
Production Insight
Mapping errors incorrectly causes silent data issues — a handler that catches the wrong exception class catches nothing, and the real error propagates unhandled.
Always test error paths with intentional failures: insert a duplicate, exceed a column length, violate a foreign key — verify each exception block fires.
PostgreSQL's unique_violation includes CONSTRAINT_NAME in the diagnostics — capture it and include it in your log entry for immediate identification without querying the schema.
Key Takeaway
Map exception names first, then SQLSTATE codes — numeric ORA codes have no PostgreSQL equivalent.
PostgreSQL has fewer predefined exception names than Oracle, but SQLSTATE codes cover the full SQL standard exception space.
Bottom line: if you carry Oracle exception names without verifying they exist in PostgreSQL, your handlers will never fire and errors will propagate unhandled.
Error Handling Strategy Selection
IfNeed row-by-row error logging with continued processing (Oracle: SAVE EXCEPTIONS in FORALL)
→
UseUse a LOOP with an inner BEGIN...EXCEPTION block — log each failure to error_log using GET STACKED DIAGNOSTICS, then CONTINUE to the next row
IfNeed all-or-nothing transaction (Oracle: standard exception propagation)
→
UseUse a single outer BEGIN...EXCEPTION at the transaction level — any unhandled exception causes the entire transaction to abort and roll back
IfNeed audit logging that survives a transaction rollback (Oracle: PRAGMA AUTONOMOUS_TRANSACTION)
→
UseUse dblink to execute the log INSERT in a separate connection — or write to a file-based log via RAISE LOG which is not transaction-bound
IfNeed to distinguish which specific constraint was violated
→
UseCatch unique_violation or check_violation, then GET STACKED DIAGNOSTICS with CONSTRAINT_NAME — map constraint names to application error codes in the handler
Preserving Error Context Without ORA-06512
Oracle's ORA-06512 provides the call stack and line numbers automatically — no developer action required. Every unhandled exception propagates with full context: schema, package, procedure name, and the exact PL/SQL line number. PostgreSQL provides none of this automatically. Without explicit instrumentation, a failed procedure surfaces only its SQLSTATE code and a terse message with no location, no call chain, and no data context.
GET STACKED DIAGNOSTICS is PostgreSQL's mechanism for capturing exception context. It must be called inside an EXCEPTION block — not in regular procedural code, and not after the block exits. It captures: RETURNED_SQLSTATE (the five-character SQLSTATE code), MESSAGE_TEXT (the primary error message), PG_EXCEPTION_DETAIL (secondary detail if present), PG_EXCEPTION_HINT (optional hint text), PG_EXCEPTION_CONTEXT (the function call chain), CONSTRAINT_NAME (for constraint violation exceptions), COLUMN_NAME, DATATYPE_NAME, and TABLE_NAME (where available).
PG_EXCEPTION_CONTEXT is the closest analog to ORA-06512. It shows the call chain of PL/pgSQL functions at the time of the exception — for example: 'PL/pgSQL function process_order(integer) line 42 at SQL statement'. However, it does not always include line numbers, and it may be truncated for deep call chains. For complex systems, supplement it with explicit step tracking using a session variable or a parameter passed down the call chain.
The correct pattern for migrated code is a three-layer approach. First, capture the full context using GET STACKED DIAGNOSTICS. Second, log it to a persistent error_log table using a separate connection (via dblink) so the log survives a transaction rollback. Third, re-raise the exception with enough context in the message that the caller — whether an application or a higher-level procedure — can identify the failure without querying the error log.
-- ============================================================
-- CENTRALIZEDERRORLOGGINGINFRASTRUCTURE
-- ============================================================
-- Step1: Create the error log table
CREATETABLEIFNOTEXISTS io.thecodeforge.error_log (
log_id BIGSERIALPRIMARYKEY,
procedure_name TEXTNOTNULL,
parameters TEXT,
sqlstate CHAR(5),
message TEXT,
detail TEXT,
hint TEXT,
context TEXT,
constraint_name TEXT,
session_user TEXTDEFAULT SESSION_USER,
application_name TEXTDEFAULTcurrent_setting('application_name'),
logged_at TIMESTAMPTZDEFAULTNOW()
);
CREATEINDEX idx_error_log_logged_at
ON io.thecodeforge.error_log(logged_at DESC);
CREATEINDEX idx_error_log_procedure
ON io.thecodeforge.error_log(procedure_name, logged_at DESC);
-- Step2: Centralized log function
-- Note: This function runs OUTSIDE a transaction via dblink
-- so it persists even when the calling transaction rolls back
CREATEORREPLACEFUNCTION io.thecodeforge.log_error(
p_procedure TEXT,
p_parameters TEXT,
p_sqlstate TEXT,
p_message TEXT,
p_detail TEXTDEFAULTNULL,
p_context TEXTDEFAULTNULL,
p_constraint TEXTDEFAULTNULL
) RETURNSVOIDAS $$
DECLARE
v_conn TEXT;
BEGIN
-- Use dblink to write outside the current transaction
-- so the log entry survives a rollback
PERFORMdblink_connect(
'io_error_log',
format('dbname=%s user=%s password=%s',
current_database(), SESSION_USER,
current_setting('app.db_password', true)
)
);
PERFORMdblink_exec('io_error_log', format(
'INSERT INTO io.thecodeforge.error_log ''(procedure_name, parameters, sqlstate, message, detail, context, constraint_name) ''VALUES (%L, %L, %L, %L, %L, %L, %L)',
p_procedure, p_parameters, p_sqlstate,
p_message, p_detail, p_context, p_constraint
));
PERFORMdblink_disconnect('io_error_log');
EXCEPTIONWHENOTHERSTHEN
-- If the log write fails, fall back to server log
-- Never let logging failure hide the original error
RAISELOG'Error log write failed for procedure=%: sqlstate=%, message=%',
p_procedure, p_sqlstate, p_message;
END;
$$ LANGUAGE plpgsql SECURITYDEFINER;
-- Step3: Patternfor any migrated procedure
CREATEORREPLACEFUNCTION io.thecodeforge.process_order(
p_order_id INTEGER
) RETURNSVOIDAS $$
DECLARE
v_sqlstate TEXT;
v_message TEXT;
v_detail TEXT;
v_hint TEXT;
v_context TEXT;
v_constraint TEXT;
v_customer_id INTEGER;
v_order_amount NUMERIC;
BEGIN
-- Log entry fortracing (equivalent to Oracle's DBMS_OUTPUT or debug logging)
RAISEDEBUG'process_order: starting for order_id=%', p_order_id;
-- Business logic
SELECT customer_id, total_amount
INTOSTRICT v_customer_id, v_order_amount
FROM orders
WHERE order_id = p_order_id;
-- STRICT makes SELECTINTO raise NO_DATA_FOUND or TOO_MANY_ROWS
-- just like Oracle's SELECTINTO behavior
RAISEDEBUG'process_order: customer_id=%, amount=%',
v_customer_id, v_order_amount;
-- ... rest of business logic ...
EXCEPTIONWHEN no_data_found THENPERFORM io.thecodeforge.log_error(
'process_order',
'order_id=' || p_order_id,
'P0002', 'Order not found',
NULL, NULL, NULL
);
RAISEEXCEPTION'Order not found: order_id=%', p_order_id
USINGERRCODE = 'P0002';
WHEN too_many_rows THENPERFORM io.thecodeforge.log_error(
'process_order',
'order_id=' || p_order_id,
'21000', 'Ambiguous order ID — multiple rows found',
NULL, NULL, NULL
);
RAISEEXCEPTION'Ambiguous order: order_id=% matches multiple rows', p_order_id
USINGERRCODE = '21000';
WHENOTHERSTHEN
-- Capture the full context — this is your ORA-06512 replacement
GETSTACKEDDIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_detail = PG_EXCEPTION_DETAIL,
v_hint = PG_EXCEPTION_HINT,
v_context = PG_EXCEPTION_CONTEXT,
v_constraint = CONSTRAINT_NAME;
-- Log immediately before re-raise
PERFORM io.thecodeforge.log_error(
'process_order',
'order_id=' || p_order_id,
v_sqlstate, v_message,
v_detail, v_context, v_constraint
);
-- Re-raise with enriched message for the caller
RAISEEXCEPTION'Order processing failed: order_id=%, error=%, sqlstate=%',
p_order_id, v_message, v_sqlstate
USINGERRCODE = v_sqlstate,
DETAIL = v_context;
END;
$$ LANGUAGE plpgsql;
-- Step4: Query the error log during incident response
SELECT
logged_at,
procedure_name,
parameters,
sqlstate,
message,
LEFT(context, 500) AS context_preview,
constraint_name
FROM io.thecodeforge.error_log
WHERE logged_at > NOW() - INTERVAL'1 hour'ORDERBY logged_at DESCLIMIT50;
Context Loss Warning
GET STACKED DIAGNOSTICS only works inside EXCEPTION blocks — calling it in regular procedural code returns empty values silently
PG_EXCEPTION_CONTEXT shows the function call chain but may not include the PL/pgSQL line number within the current function — add explicit step tracking for complex functions
Log to a table via dblink, not inside the main transaction — a log INSERT in the same transaction is lost if the transaction rolls back, which is exactly when you need the log most
RAISE LOG bypasses the transaction and writes directly to the server log — it is your fallback if the dblink logging fails
Context strings can be long — truncate at a known length when inserting to avoid column overflow errors in the log table itself
Production Insight
Context capture via GET STACKED DIAGNOSTICS adds roughly 2ms per exception call — this is negligible for error paths but matters in high-frequency loops where exceptions are used for control flow.
In high-throughput batch systems, consider sampling: log every Nth exception rather than all exceptions when the error rate is high and the error type is known.
PG_EXCEPTION_CONTEXT includes function names and call depth — for complex multi-function call chains this is often sufficient to identify the failure location without line numbers.
Key Takeaway
GET STACKED DIAGNOSTICS is your ORA-06512 replacement — it requires explicit invocation inside every EXCEPTION block where context matters.
Always capture at minimum RETURNED_SQLSTATE and MESSAGE_TEXT — add PG_EXCEPTION_CONTEXT for call chain visibility and CONSTRAINT_NAME for integrity violations.
Bottom line: log to a table via a separate transaction — a log entry inside the failed transaction disappears with the rollback, which is exactly when you need it most.
Transaction Control Differences
Oracle's transaction model and PostgreSQL's differ in ways that are subtle enough to pass basic testing but cause production data integrity issues under load. Understanding these differences before migration is cheaper than debugging them after.
In Oracle, a transaction starts implicitly with the first DML statement and ends with an explicit COMMIT or ROLLBACK. An exception in a PL/SQL block does not automatically roll back the transaction unless the exception is unhandled and propagates to the top level. An exception handler can INSERT, UPDATE, and COMMIT inside its body — the transaction remains active.
In PostgreSQL, the behavior inside PL/pgSQL functions differs from the behavior at the session level. Within a PL/pgSQL function, an unhandled exception causes the transaction to be marked as aborted — no further SQL executes until the transaction is rolled back. However, if the exception is caught by an EXCEPTION block, the function continues and the transaction remains active. PostgreSQL uses implicit subtransactions around each EXCEPTION block — this is the source of the performance overhead.
Oracle's AUTONOMOUS_TRANSACTION pragma, which allows an inner procedure to commit independently of the outer transaction (the canonical use case being audit logging that persists even when the main transaction rolls back), has no native PostgreSQL equivalent. The two practical alternatives are: use the dblink extension to open a second connection to the same database and execute the audit INSERT there (it commits independently), or use RAISE LOG to write to the PostgreSQL server log (which is not transaction-bound and always persists).
SAVEPOINT behavior is one area where the databases converge. Both Oracle and PostgreSQL support SAVEPOINT name, ROLLBACK TO name, and RELEASE name. The syntax is the same. The semantics are the same. The performance cost differs: in Oracle, savepoints are cheap. In PostgreSQL, each SAVEPOINT creates a subtransaction with associated overhead — use them only when partial rollback is a genuine business requirement, not as a general error recovery mechanism.
-- ============================================================-- ORACLE PATTERN: Implicit transaction with partial rollback-- ============================================================-- Oracle: transaction stays active even after a handled exception-- The audit INSERT is committed even if the UPDATE failsCREATEORREPLACEPROCEDUREoracle_pattern(p_account_id NUMBER) ASBEGIN-- This INSERT is part of the current transactionINSERTINTOaudit_log (event, account_id, logged_at)
VALUES ('PAYMENT_ATTEMPT', p_account_id, SYSDATE);
SAVEPOINT before_debit;
-- This UPDATE may failUPDATE accounts
SET balance = balance - 100WHERE account_id = p_account_id;
EXCEPTIONWHENOTHERSTHENROLLBACKTO before_debit; -- Only rolls back the UPDATE-- The audit_log INSERT is preserved (still in the transaction)COMMIT; -- Commits the audit entryRAISE; -- Re-raises the original exceptionEND;
/
-- ============================================================-- POSTGRESQL EQUIVALENT: Explicit savepoint with dblink audit-- ============================================================CREATEORREPLACEFUNCTION io.thecodeforge.postgres_pattern(
p_account_id INTEGER
) RETURNSVOIDAS $$
DECLARE
v_sqlstate TEXT;
v_message TEXT;
v_context TEXT;
BEGIN-- PostgreSQL: savepoint for partial rollbackSAVEPOINT before_debit;
UPDATE accounts
SET balance = balance - 100WHERE account_id = p_account_id;
-- If we get here, the update succeeded-- Write audit log AFTER success, inside the same transactionINSERTINTOaudit_log (event, account_id, logged_at)
VALUES ('PAYMENT_SUCCESS', p_account_id, NOW());
EXCEPTIONWHENOTHERSTHEN-- Roll back only the UPDATE (and anything after the savepoint)ROLLBACKTOSAVEPOINT before_debit;
-- Use GET STACKED DIAGNOSTICS before any further SQLGETSTACKEDDIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_context = PG_EXCEPTION_CONTEXT;
-- Write audit log via dblink — this commits independently-- even though our transaction rolled back to the savepointPERFORM io.thecodeforge.log_error(
'postgres_pattern',
'account_id=' || p_account_id,
v_sqlstate, v_message, NULL, v_context, NULL
);
-- Re-raise the original exceptionRAISE;
END;
$$ LANGUAGE plpgsql;
-- ============================================================-- ORACLE AUTONOMOUS TRANSACTION PATTERN-- (writing audit logs that survive rollback)-- ============================================================-- Oracle:CREATEORREPLACEPROCEDUREoracle_write_audit(
p_event VARCHAR2,
p_detail VARCHAR2
) ASPRAGMA AUTONOMOUS_TRANSACTION;
BEGININSERTINTOaudit_log (event, detail, logged_at)
VALUES (p_event, p_detail, SYSDATE);
COMMIT; -- This commit does NOT affect the outer transactionEND;
/
-- PostgreSQL equivalent via dblink:CREATEORREPLACEFUNCTION io.thecodeforge.write_audit(
p_event TEXT,
p_detail TEXT
) RETURNSVOIDAS $$
BEGIN-- dblink executes in a SEPARATE connection — committed immediately-- regardless of whether the calling transaction commits or rolls backPERFORMdblink_exec(
'dbname=' || current_database() || ' user=' || SESSION_USER,
format(
'INSERT INTO io.thecodeforge.audit_log (event, detail, logged_at) ''VALUES (%L, %L, NOW())',
p_event, p_detail
)
);
EXCEPTIONWHENOTHERSTHEN-- Audit failure must never mask the original operation failure-- Fall back to server log if dblink write failsRAISELOG'Audit write failed: event=%, detail=%, error=%',
p_event, p_detail, SQLERRM;
END;
$$ LANGUAGE plpgsql;
-- ============================================================-- PERFORMANCE: Comparing transaction patterns-- ============================================================-- Benchmark query — run for baseline before migrationEXPLAIN (ANALYZE, BUFFERS, FORMATTEXT)
WITH timing AS (
SELECTnow() AS start_time,
count(*) AS source_rows
FROM staging_orders
WHERE processed = FALSE
)
SELECT
source_rows,
now() - start_time AS planning_time
FROM timing;
Savepoint Performance Guidelines
Each SAVEPOINT creates an implicit subtransaction — use sparingly, only when partial rollback is a business requirement not an engineering convenience
ROLLBACK TO SAVEPOINT is cheaper than a full ROLLBACK — but the cost of the savepoint itself adds up in loops processing thousands of rows
PostgreSQL automatically rolls back the current transaction on unhandled exceptions in interactive sessions — inside PL/pgSQL functions, unhandled exceptions abort the function but the outer transaction may still be recoverable
Oracle's AUTONOMOUS_TRANSACTION has no PostgreSQL native equivalent — dblink is the practical replacement and adds network round-trip overhead
RAISE LOG writes to the server log immediately, outside the transaction boundary — use it as a cheap alternative to dblink for audit logging when approximate timestamps are acceptable
Production Insight
Excessive SAVEPOINTs in a batch loop degrade throughput by 10 to 20 percent — each one adds subtransaction overhead.
Test transaction rollback semantics explicitly in staging: verify that an exception mid-function leaves the database in the expected state — rolled back to the savepoint, or fully rolled back, depending on your design.
For audit logging that must survive rollback, dblink is the correct PostgreSQL pattern — but it requires the dblink extension installed and a valid connection string, which requires planning before migration.
Key Takeaway
PostgreSQL transactions are more explicit than Oracle's — an unhandled exception inside a PL/pgSQL function aborts the entire transaction.
SAVEPOINT enables partial rollback with the same syntax as Oracle, but each savepoint has measurable performance cost in high-volume loops.
Bottom line: the AUTONOMOUS_TRANSACTION pattern is the most significant architectural gap — plan for dblink-based audit logging before the migration begins, not after the first rollback swallows an audit entry.
Transaction Recovery Pattern Selection
IfError is recoverable and you need to continue processing (e.g., skip a bad row and continue the batch)
→
UseUse SAVEPOINT before the operation, catch the exception, ROLLBACK TO SAVEPOINT, log the failure, CONTINUE or proceed to next iteration
IfError is fatal and no partial work should be committed (all-or-nothing business requirement)
→
UseLet the exception propagate unhandled — PostgreSQL will abort the transaction. Or re-raise after logging. Do not use SAVEPOINT.
IfNeed to write an audit log entry that survives the main transaction rollback (Oracle: AUTONOMOUS_TRANSACTION)
→
UseUse dblink to execute the INSERT in a separate connection, or RAISE LOG to write to the server log (not transaction-bound)
IfNeed to track partial success in a batch where some rows succeed and others fail
→
UseUse a row-level EXCEPTION block inside the processing loop — log each failure, increment a failure counter, CONTINUE — raise a summary exception after the loop if failures exceed threshold
PL/pgSQL Control Flow and Cursor Differences
Beyond exception handling, the control flow and cursor mechanics between PL/SQL and PL/pgSQL diverge in ways that surface as runtime errors rather than compilation failures — which makes them hard to catch in testing.
Oracle's implicit cursor attributes (SQL%ROWCOUNT, SQL%FOUND, SQL%NOTFOUND) work the same way in PL/pgSQL for basic DML. SQL%ROWCOUNT maps to GET DIAGNOSTICS row_count = ROW_COUNT. SQL%FOUND and SQL%NOTFOUND have no direct equivalents — use the row_count approach.
Oracle's FOR loop over a cursor fetches rows implicitly and is the idiomatic pattern. PostgreSQL supports the same FOR rec IN SELECT... LOOP construct with near-identical syntax. The difference is in exception handling inside the loop: Oracle's cursor loop exits gracefully on NO_DATA_FOUND. PostgreSQL's FOR loop also handles this, but explicit cursor operations (OPEN, FETCH, CLOSE) require manual NOT FOUND checks.
Oracle's BULK COLLECT and FORALL — the bulk processing operations that are the primary mechanism for avoiding row-by-row processing overhead — have no direct PostgreSQL equivalent. The closest patterns are: INSERT INTO ... SELECT ... for bulk insert, UPDATE with FROM for bulk update, and arrays combined with UNNEST for parameterized bulk operations. For the SAVE EXCEPTIONS pattern (continue processing and log individual failures), use a PL/pgSQL loop with an inner EXCEPTION block.
REFCURSOR behavior is similar but the syntax for opening and passing cursors between procedures differs. Oracle uses SYS_REFCURSOR. PostgreSQL uses refcursor. Both support dynamic SQL in cursors and both support passing open cursors between procedures, but the parameter passing mechanism differs in ways that require refactoring rather than simple renaming.
io/thecodeforge/migration/control_flow.sqlPLPGSQL
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
-- ============================================================
-- ORACLEBULKCOLLECT + FORALLPATTERN
-- ============================================================
-- Oracle:
CREATEORREPLACEPROCEDURE oracle_bulk_process ASTYPE t_ids ISTABLEOFNUMBER;
v_ids t_ids;
BEGINSELECT order_id BULKCOLLECTINTO v_ids
FROM staging_orders
WHERE processed = 'N';
FORALL i IN1 .. v_ids.COUNTSAVEEXCEPTIONSUPDATE orders
SET status = 'PROCESSED'WHERE order_id = v_ids(i);
COMMIT;
EXCEPTIONWHENOTHERSTHENIFSQLCODE = -24381THEN -- bulk errors
FOR i IN1 .. SQL%BULK_EXCEPTIONS.COUNTLOOP
DBMS_OUTPUT.PUT_LINE(
'Row ' || SQL%BULK_EXCEPTIONS(i).ERROR_INDEX ||
' failed: ORA-' || SQL%BULK_EXCEPTIONS(i).ERROR_CODE
);
ENDLOOP;
ENDIF;
END;
/
-- ============================================================
-- POSTGRESQLEQUIVALENT: Set-based + row-level error handling
-- ============================================================
CREATEORREPLACEFUNCTION io.thecodeforge.postgres_bulk_process()
RETURNSTABLE(processed_count INTEGER, failed_count INTEGER) AS $$
DECLARE
v_order RECORD;
v_processed INTEGER := 0;
v_failed INTEGER := 0;
v_sqlstate TEXT;
v_message TEXT;
v_context TEXT;
BEGIN
-- Option A: Pure set-based (fastest, no per-row error handling)
UPDATE orders o
SET status = 'PROCESSED',
processed_at = NOW()
FROM staging_orders s
WHERE o.order_id = s.order_id
AND s.processed = FALSE;
GETDIAGNOSTICS v_processed = ROW_COUNT;
RETURNQUERYSELECT v_processed, 0;
-- Option B: Row-level loop with per-row error handling
-- (equivalent to Oracle's FORALL with SAVEEXCEPTIONS)
-- Uncommentthis block if you need to continue on individual row failures
/*
FOR v_order INSELECT order_id FROM staging_orders WHERE processed = FALSELOOPBEGINUPDATE orders
SET status = 'PROCESSED', processed_at = NOW()
WHERE order_id = v_order.order_id;
v_processed := v_processed + 1;
EXCEPTIONWHENOTHERSTHENGETSTACKEDDIAGNOSTICS
v_sqlstate = RETURNED_SQLSTATE,
v_message = MESSAGE_TEXT,
v_context = PG_EXCEPTION_CONTEXT;
PERFORM io.thecodeforge.log_error(
'postgres_bulk_process',
'order_id=' || v_order.order_id,
v_sqlstate, v_message, NULL, v_context, NULL
);
v_failed := v_failed + 1;
-- CONTINUE to next row (no RAISE)
END;
ENDLOOP;
RETURNQUERYSELECT v_processed, v_failed;
*/
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- IMPLICITCURSORATTRIBUTEMIGRATION
-- ============================================================
-- OracleSQL%ROWCOUNT -> PostgreSQLGETDIAGNOSTICS
-- Oracle:
BEGINUPDATE accounts SET balance = balance - 100WHERE account_id = 42;
IFSQL%ROWCOUNT = 0THEN
RAISE_APPLICATION_ERROR(-20001, 'Account not found');
ENDIF;
END;
/
-- PostgreSQL:
DO $$
DECLARE
v_count INTEGER;
BEGINUPDATE accounts SET balance = balance - 100WHERE account_id = 42;
GETDIAGNOSTICS v_count = ROW_COUNT;
IF v_count = 0THENRAISEEXCEPTION'Account not found: account_id=42'USINGERRCODE = 'P0002';
ENDIF;
END;
$$;
-- ============================================================
-- REFCURSORMIGRATION
-- ============================================================
-- Oracle:
CREATEORREPLACEPROCEDUREget_orders(
p_status INVARCHAR2,
p_cursor OUT SYS_REFCURSOR
) ASBEGINOPEN p_cursor FORSELECT order_id, customer_id, amount
FROM orders
WHERE status = p_status;
END;
/
-- PostgreSQL:
CREATEORREPLACEFUNCTION io.thecodeforge.get_orders(
p_status TEXT
) RETURNS refcursor AS $$
DECLARE
v_cursor refcursor;
BEGINOPEN v_cursor FORSELECT order_id, customer_id, amount
FROM orders
WHERE status = p_status;
RETURN v_cursor;
END;
$$ LANGUAGE plpgsql;
Bulk Processing Migration Strategy
Oracle BULK COLLECT + FORALL → PostgreSQL: single set-based UPDATE/INSERT/DELETE where possible — no PL/pgSQL loop needed
Oracle FORALL with SAVE EXCEPTIONS → PostgreSQL: FOR loop with inner BEGIN...EXCEPTION block — log each failure, continue the loop
Oracle SQL%ROWCOUNT → PostgreSQL: GET DIAGNOSTICS v_count = ROW_COUNT — must be called immediately after the DML statement
Oracle SYS_REFCURSOR → PostgreSQL: refcursor — similar semantics, different parameter passing syntax
Oracle cursor FOR loop → PostgreSQL: FOR rec IN SELECT ... LOOP — nearly identical syntax, same implicit open/fetch/close
Production Insight
Set-based operations (UPDATE with FROM, INSERT with SELECT) are the fastest path for bulk processing in PostgreSQL — avoid PL/pgSQL loops entirely when the logic allows it.
For row-level error tolerance (the SAVE EXCEPTIONS pattern), the PL/pgSQL FOR loop with inner EXCEPTION block works but is 15 to 30 times slower than a set-based operation — use it only when per-row error logging is a genuine requirement.
Migrating BULK COLLECT + FORALL directly to the loop equivalent without trying the set-based approach first is the most common performance regression in Oracle-to-PostgreSQL migrations.
Key Takeaway
Oracle's BULK COLLECT + FORALL maps to set-based SQL in PostgreSQL — the loop approach is a fallback for per-row error tolerance, not the default.
SQL%ROWCOUNT maps to GET DIAGNOSTICS — the pattern is the same, the syntax differs.
Bottom line: if your first migration attempt replaces FORALL with a PL/pgSQL FOR loop, benchmark it — a single UPDATE with FROM is almost always 10x to 50x faster.
● Production incidentPOST-MORTEMseverity: high
Silent Data Corruption in Batch Processing — Zero Rows for Three Days
Symptom
The batch job reported success with exit code 0. Application logs showed the job started and completed. Row count metrics showed zero rows processed, but the alerting threshold was set at negative numbers (expecting failures), not at unexpectedly low counts. No errors appeared in PostgreSQL logs. No exceptions were surfaced to the application.
Assumption
The development team had carried over the WHEN OTHERS THEN NULL exception handler from the Oracle codebase verbatim. In Oracle, even with this handler, the ORA-06512 stack would have been visible in the Oracle audit trail and session error logs. The team assumed PostgreSQL would behave similarly — that errors would surface somewhere even if the handler swallowed them at the application level.
Root cause
The migrated PL/pgSQL function contained an EXCEPTION WHEN OTHERS THEN NULL block around the core processing loop. A permission error on a temporary staging table — introduced when a DBA revoked a role that had been accidentally granted — was caught by this handler on every iteration. The function continued without processing any rows, returning success to the caller. In Oracle, the original code's WHEN OTHERS THEN NULL would have been equally bad practice, but Oracle's session-level error logging and ORA-06512 propagation would have left traces in v$session, the alert log, and potentially in application-side JDBC exception chains. PostgreSQL logged nothing because the exception was handled before it reached the server log threshold, and the handler emitted no output.
Fix
Four changes were made in sequence:
1. Removed all WHEN OTHERS THEN NULL handlers across the codebase — replaced each with either a specific exception handler for the recoverable case or a WHEN OTHERS THEN with full GET STACKED DIAGNOSTICS capture and RAISE.
2. Implemented a centralized error logging function using GET STACKED DIAGNOSTICS to capture RETURNED_SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_DETAIL, and PG_EXCEPTION_CONTEXT, inserting into an error_log table via a separate connection.
3. Added row count assertions after each processing batch — if processed_count equals zero and source_count is greater than zero, raise an explicit application error.
4. Set log_min_error_statement = 'error' and log_error_verbosity = 'verbose' in postgresql.conf, and confirmed pg_reload_conf() was called to apply the settings.
Key lesson
Never carry WHEN OTHERS THEN NULL from Oracle to PostgreSQL — in Oracle it was bad practice that at least left traces; in PostgreSQL it is a silent kill switch on your error visibility
PostgreSQL requires explicit error context capture — nothing surfaces automatically the way ORA-06512 does — instrument every exception block before deployment
Add row count assertions to batch jobs — if the expected count does not match the actual count, fail loudly rather than returning success
Test error paths with intentional failures in staging before go-live — a permission error, a constraint violation, a missing table — each one should produce a visible, actionable log entry
Production debug guideSymptom → Action mapping for PostgreSQL migration issues6 entries
Symptom · 01
Function fails with 'control reached end of function without RETURN'
→
Fix
All code paths in a PL/pgSQL function that returns a value must end with a RETURN statement. Add explicit RETURN at the end of the function body and ensure every conditional branch that can reach the end also returns. For functions returning VOID, add RETURN at the end — PostgreSQL still enforces this in some versions. Also check that exception blocks include their own RETURN or RAISE to prevent fall-through.
Symptom · 02
GET STACKED DIAGNOSTICS returns empty strings or NULL values
→
Fix
Confirm that the GET STACKED DIAGNOSTICS call is inside an EXCEPTION block, not in regular procedural code. The STACKED variant (not CURRENT) retrieves information about the exception that triggered the block. If you call it outside an EXCEPTION block it returns empty values. Also verify that the variable types are TEXT — RETURNED_SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_DETAIL, and PG_EXCEPTION_CONTEXT are all text fields.
Symptom · 03
Exception is caught but no line number appears in logs
→
Fix
PostgreSQL does not include line numbers in exception context by default. PG_EXCEPTION_CONTEXT includes the function call chain (function name, schema, call depth) but not the PL/pgSQL line number within the function. To work around this, add explicit RAISE NOTICE or RAISE LOG statements at key execution points with a step identifier before the block where the exception may occur. For critical procedures, consider adding entry logging with parameters so the log trail shows the execution path.
Symptom · 04
Performance degradation of 15 to 30 percent after adding exception blocks around loops
→
Fix
Each EXCEPTION block in PostgreSQL creates a subtransaction internally, which adds overhead. Move validation logic before the transaction begins so exceptions are less likely to be raised inside the loop. Consider restructuring INSERT or UPDATE statements to use ON CONFLICT for upsert patterns rather than catching unique_violation in a loop. Use SAVEPOINT only for truly recoverable operations where partial commit is a business requirement.
Symptom · 05
Code that worked in Oracle raises 'invalid_transaction_state' in PostgreSQL
→
Fix
PostgreSQL is stricter about transaction state — certain DDL statements and commands that Oracle allows inside transaction blocks are prohibited in PostgreSQL. Check whether the migrated code contains CREATE TABLE, DROP TABLE, TRUNCATE, or other DDL inside a transaction block. Move DDL outside transactions, or use advisory locks to serialize access if DDL is truly needed inside the workflow.
Symptom · 06
PRAGMA AUTONOMOUS_TRANSACTION patterns produce no equivalent behavior
→
Fix
PostgreSQL has no native autonomous transaction support. The most common use case — writing to an audit or error log table even when the main transaction rolls back — requires either the dblink extension (connecting back to the same database and executing in a separate connection) or the pg_background extension (executing in a background worker). For error logging specifically, consider writing logs to a file via RAISE LOG or to a syslog sink rather than a table inside the same transaction.
★ PostgreSQL Error Debugging Cheat SheetImmediate actions for the most common error handling failures after migration from Oracle
No error context in logs — errors appear as single-line messages with no location or data−
Immediate action
Add GET STACKED DIAGNOSTICS to every exception block to capture SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_DETAIL, and PG_EXCEPTION_CONTEXT
Commands
SELECT pid, usename, application_name, state, query_start, state_change, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;
Add EXCEPTION block with GET STACKED DIAGNOSTICS and RAISE LOG 'SQLSTATE=%, Message=%, Context=%', v_sqlstate, v_message, v_context — then check that log_min_error_statement = 'error' in postgresql.conf
Unhandled exceptions crash connections or abort transactions with no useful message+
Immediate action
Wrap critical sections in BEGIN...EXCEPTION blocks and ensure all exception handlers re-raise with context
Commands
ALTER SYSTEM SET log_min_error_statement = 'error'; ALTER SYSTEM SET log_error_verbosity = 'verbose';
SELECT pg_reload_conf();
Fix now
Implement a top-level exception handler in the outermost calling function that captures GET STACKED DIAGNOSTICS, logs to error_log table or RAISE LOG, then re-raises the original exception
Batch job returns success but processed row count is zero or wrong+
Immediate action
Add row count assertions after each processing step — never trust a zero count as success when the source has rows
Commands
SELECT schemaname, relname, n_live_tup, n_dead_tup, last_vacuum, last_analyze FROM pg_stat_user_tables WHERE relname IN ('source_table', 'target_table');
SELECT count(*) FROM source_table WHERE processed_flag = 'N';
Fix now
Add assertion: IF v_processed_count = 0 AND v_source_count > 0 THEN RAISE EXCEPTION 'Batch processed zero rows from % available source rows', v_source_count USING ERRCODE = 'P0001'; END IF;
unique_violation exceptions not caught — code was catching DUP_VAL_ON_INDEX+
Immediate action
Search the codebase for DUP_VAL_ON_INDEX and replace with unique_violation
Commands
grep -r 'DUP_VAL_ON_INDEX' /path/to/sql/files/
SELECT conname, contype, conrelid::regclass FROM pg_constraint WHERE contype = 'u' AND conrelid = 'your_table'::regclass;
Fix now
Replace WHEN DUP_VAL_ON_INDEX with WHEN unique_violation — and add SQLSTATE interrogation: GET STACKED DIAGNOSTICS v_constraint = CONSTRAINT_NAME to identify which constraint was violated
Oracle vs PostgreSQL Error Handling Comparison
Aspect
Oracle PL/SQL
PostgreSQL PL/pgSQL
Migration Impact
Error Stack (call chain + line numbers)
Automatic ORA-06512 with schema, package, procedure, and line number on every unhandled exception
Manual capture via GET STACKED DIAGNOSTICS inside EXCEPTION block — PG_EXCEPTION_CONTEXT gives function names but not always line numbers
High — requires explicit instrumentation in every exception block before go-live
Transaction Control on Exception
Exception does not automatically roll back the transaction — handler can commit partial work
Unhandled exception aborts the transaction — EXCEPTION block uses implicit subtransaction; handler can continue the outer transaction
Medium — review every exception handler that commits partial work; test rollback semantics explicitly
PRAGMA AUTONOMOUS_TRANSACTION — native, zero configuration, widely used for audit logging
No native equivalent — requires dblink extension or pg_background; connection overhead applies
High — audit logging patterns require architectural redesign before migration
Bulk DML Error Handling
FORALL with SAVE EXCEPTIONS — continues on row failures, collects all errors in SQL%BULK_EXCEPTIONS
FOR loop with inner EXCEPTION block — functional equivalent but significantly slower for large volumes
Medium to High — assess whether per-row error tolerance is required or set-based SQL suffices
Performance of Exception Blocks
Exception handling overhead is minimal and optimized
Each EXCEPTION block creates an implicit subtransaction — roughly 2ms per exception raised; excessive use in loops degrades throughput
High — restructure loops to validate before attempting rather than catch and recover
Post-Mortem Debugging Context
Full call stack in ORA-06512 frames; DBMS_UTILITY.FORMAT_ERROR_BACKTRACE available
GET STACKED DIAGNOSTICS provides SQLSTATE, message, and function chain; no built-in equivalent to FORMAT_ERROR_BACKTRACE
High — implement logging framework before go-live; add error_log table and log_error function to all procedures
Key takeaways
1
PostgreSQL has no automatic ORA-06512 equivalent
you must build context capture using GET STACKED DIAGNOSTICS in every exception block before deployment, not after the first production incident
2
Map Oracle exception names to PostgreSQL equivalents by semantic meaning, not numeric code
most named exceptions have a direct PostgreSQL equivalent; DUP_VAL_ON_INDEX becomes unique_violation
3
GET STACKED DIAGNOSTICS is your ORA-06512 replacement
capture RETURNED_SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_CONTEXT, and CONSTRAINT_NAME at minimum; log to a table via dblink so the entry survives rollback
4
WHEN OTHERS THEN NULL is catastrophically dangerous in PostgreSQL
unlike Oracle where ORA-06512 provides a trace, PostgreSQL logs absolutely nothing when this pattern is used
5
PRAGMA AUTONOMOUS_TRANSACTION has no native PostgreSQL equivalent
plan for dblink-based audit logging before migration begins, not after the first rollback swallows an audit entry
restructure code to validate before attempting rather than catch and recover, especially in high-volume batch loops
7
Oracle's FORALL + SAVE EXCEPTIONS maps to either set-based SQL (fastest) or a PL/pgSQL FOR loop with inner EXCEPTION block (per-row error tolerance)
the loop approach is 10 to 50 times slower
Common mistakes to avoid
6 patterns
×
Carrying WHEN OTHERS THEN NULL from Oracle to PostgreSQL verbatim
Symptom
Batch jobs report success with exit code 0 but process zero rows. No errors appear in application or database logs. Data is not updated. The problem is discovered days later when downstream reports show stale data.
Fix
Remove all WHEN OTHERS THEN NULL handlers. Replace each with either a specific handler for the recoverable error or a WHEN OTHERS THEN block that captures GET STACKED DIAGNOSTICS and then RAISES or logs before returning. Every exception must be visible — silencing exceptions is never acceptable in production.
×
Assuming Oracle error numbers work in PostgreSQL exception blocks
Symptom
EXCEPTION WHEN invalid_number THEN or EXCEPTION WHEN 01403 THEN never catches anything because the Oracle error number format does not exist in PostgreSQL. Real errors propagate unhandled, crashing the function or aborting the transaction.
Fix
Map Oracle exception names to their PostgreSQL equivalents using the mapping table in this guide. Use named exceptions (NO_DATA_FOUND, unique_violation) rather than SQLSTATE codes where possible for readability. Test every exception handler with an intentional failure before deploying to production.
×
Not capturing error context — relying on the error message alone
Symptom
Production incidents show 'unique violation' or 'foreign key violation' with no indication of which table, which constraint, which row, or which calling function. The on-call engineer must reproduce the issue to determine the cause, adding 30 to 60 minutes to MTTR.
Fix
Add GET STACKED DIAGNOSTICS to every EXCEPTION block. Capture RETURNED_SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_CONTEXT, and CONSTRAINT_NAME. Log this to an error_log table via dblink (outside the failed transaction) so the log entry survives a rollback. Front-load entity IDs in the re-raised exception message.
×
Using SAVEPOINT excessively in batch processing loops
Symptom
Batch processing throughput drops 20 to 40 percent after migration. Connection pool utilization increases. Long-running transactions hold locks longer than expected. Database CPU is high despite the same logical work as the Oracle version.
Fix
Audit all SAVEPOINT usage. Remove SAVEPOINTs that exist only for error recovery in loops where the set-based SQL alternative works. Keep SAVEPOINTs only where partial rollback is a genuine business requirement. Replace row-by-row SAVEPOINT patterns with set-based SQL and let the database handle atomicity.
×
Migrating FORALL + SAVE EXCEPTIONS directly to a PL/pgSQL loop without trying set-based SQL first
Symptom
Bulk processing that ran in seconds in Oracle takes minutes in PostgreSQL. The FOR loop with EXCEPTION block approach is functionally correct but 10 to 50 times slower than the Oracle FORALL for the same volume.
Fix
Evaluate whether per-row error tolerance is a genuine business requirement. If the goal is 'process all rows and log failures', use a single set-based UPDATE or INSERT with ON CONFLICT for the success path. Use the FOR loop with EXCEPTION only for the exceptional case where you need to continue processing despite per-row failures at high volume.
×
Not handling the AUTONOMOUS_TRANSACTION gap before go-live
Symptom
Audit log entries disappear when the main transaction rolls back. The audit trail is incomplete, failing compliance requirements. The issue is discovered during an audit review, not during testing.
Fix
Identify all uses of PRAGMA AUTONOMOUS_TRANSACTION in the Oracle codebase before migration begins. Implement the dblink-based equivalent for each use case. Test by deliberately rolling back the main transaction and verifying the audit entry was written. If dblink is not acceptable, use RAISE LOG as a fallback for non-compliance-critical logging.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
How would you migrate a complex Oracle error handling stack that relies ...
Q02SENIOR
What is the performance impact of moving from Oracle's exception handlin...
Q03SENIOR
How do you preserve debugging context in PostgreSQL to match Oracle's OR...
Q04SENIOR
How do you handle the PRAGMA AUTONOMOUS_TRANSACTION gap during an Oracle...
Q05SENIOR
What is the most dangerous pattern to carry from Oracle PL/SQL to Postgr...
Q01 of 05SENIOR
How would you migrate a complex Oracle error handling stack that relies on ORA-06512 for debugging?
ANSWER
Start by auditing every exception handler in the codebase for three patterns: WHEN OTHERS THEN NULL (remove all of them), handlers that catch Oracle-specific exception names without verifying PostgreSQL equivalents, and any reliance on ORA-06512 being generated automatically. Then implement a centralized error logging framework: a log_error function that uses GET STACKED DIAGNOSTICS to capture RETURNED_SQLSTATE, MESSAGE_TEXT, PG_EXCEPTION_DETAIL, PG_EXCEPTION_CONTEXT, and CONSTRAINT_NAME, and writes to an error_log table via dblink so log entries survive transaction rollbacks. Map each Oracle exception name to its PostgreSQL equivalent using the SQLSTATE-based mapping. For line number tracking (which ORA-06512 provides automatically), add explicit RAISE DEBUG statements at key execution points with step identifiers. Test every error path with intentional failures before deployment.
Q02 of 05SENIOR
What is the performance impact of moving from Oracle's exception handling to PostgreSQL's EXCEPTION blocks?
ANSWER
PostgreSQL's EXCEPTION blocks create implicit subtransactions, which adds approximately 2ms overhead per exception raised — not per block, but per actual exception event. For code that raises exceptions rarely (error paths), this is negligible. For code that uses exceptions for control flow in tight loops — which Oracle developers sometimes do for things like catching NO_DATA_FOUND in cursor loops — this can degrade throughput by 15 to 30 percent. The fix is to restructure: validate before attempting rather than catch and recover. Use GET DIAGNOSTICS ROW_COUNT after UPDATE to check if a row was found rather than catching NO_DATA_FOUND. Use ON CONFLICT for upsert patterns rather than catching unique_violation. Reserve EXCEPTION blocks for genuinely exceptional conditions. Additionally, each SAVEPOINT adds overhead — audit all SAVEPOINT usage and remove any that are not serving a genuine partial-rollback requirement.
Q03 of 05SENIOR
How do you preserve debugging context in PostgreSQL to match Oracle's ORA-06512 behavior?
ANSWER
PostgreSQL requires explicit context capture via GET STACKED DIAGNOSTICS inside every EXCEPTION block where context matters. Capture RETURNED_SQLSTATE for the error category, MESSAGE_TEXT for the primary message, PG_EXCEPTION_CONTEXT for the function call chain (the closest analog to ORA-06512's stack frames), and CONSTRAINT_NAME for integrity violations. Log this to a dedicated error_log table via dblink so the log entry persists even when the failing transaction rolls back. For procedures where you need to trace execution (not just exceptions), add RAISE DEBUG statements at key steps with step identifiers and parameter values — PostgreSQL's debug-level logging can be enabled per session without affecting other connections. PG_EXCEPTION_CONTEXT provides function names and call depth but may not include the PL/pgSQL line number within a function — this is a genuine gap compared to ORA-06512 that cannot be fully bridged without adding step tracking.
Q04 of 05SENIOR
How do you handle the PRAGMA AUTONOMOUS_TRANSACTION gap during an Oracle-to-PostgreSQL migration?
ANSWER
PRAGMA AUTONOMOUS_TRANSACTION is used in Oracle to commit work (typically audit logging or error logging) independently of the outer transaction — so the log entry persists even if the main transaction rolls back. PostgreSQL has no native equivalent. The two practical solutions are: First, dblink — open a connection back to the same database (or a designated logging database) and execute the INSERT there; it commits immediately in its own connection, independent of the calling transaction. This requires the dblink extension and a valid connection string. Second, RAISE LOG — write the log entry to the PostgreSQL server log using RAISE LOG, which is not transaction-bound. The limitation is that it writes to the server log file rather than a queryable table. For compliance-critical audit logging, dblink is the correct choice. For operational debugging logs, RAISE LOG is simpler and sufficient. Identify all PRAGMA AUTONOMOUS_TRANSACTION usages before migration begins — this is an architectural change, not a syntax substitution.
Q05 of 05SENIOR
What is the most dangerous pattern to carry from Oracle PL/SQL to PostgreSQL, and why?
ANSWER
WHEN OTHERS THEN NULL. In Oracle, even with this pattern, error information propagates through ORA-06512 stack traces visible in Oracle's session error logs, audit trail, and sometimes the application's JDBC exception chain. In PostgreSQL, EXCEPTION WHEN OTHERS THEN NULL is a complete black hole — the error is caught, nothing is logged, and the function returns normally. The calling code receives a success signal. This pattern is responsible for the most dangerous class of migration defect: a process that reports success while silently failing to do any work. The fix is to remove all WHEN OTHERS THEN NULL across the codebase and replace each with either a specific handler for the genuinely recoverable case, or a WHEN OTHERS THEN block that captures GET STACKED DIAGNOSTICS, logs the failure, and then raises. Every exception must produce a visible signal — the philosophy should be 'fail loudly and log completely' rather than 'swallow and continue'.
01
How would you migrate a complex Oracle error handling stack that relies on ORA-06512 for debugging?
SENIOR
02
What is the performance impact of moving from Oracle's exception handling to PostgreSQL's EXCEPTION blocks?
SENIOR
03
How do you preserve debugging context in PostgreSQL to match Oracle's ORA-06512 behavior?
SENIOR
04
How do you handle the PRAGMA AUTONOMOUS_TRANSACTION gap during an Oracle-to-PostgreSQL migration?
SENIOR
05
What is the most dangerous pattern to carry from Oracle PL/SQL to PostgreSQL, and why?
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Does PostgreSQL have anything like Oracle's DBMS_UTILITY.FORMAT_ERROR_BACKTRACE?
Not directly. PostgreSQL's PG_EXCEPTION_CONTEXT (captured via GET STACKED DIAGNOSTICS inside an EXCEPTION block) provides similar information — function names, schema, and call depth — but it does not consistently include the PL/pgSQL line number within the current function. For deep call chains, PG_EXCEPTION_CONTEXT gives you enough to identify the failing function and the call path. For precise line numbers within a function, add explicit RAISE DEBUG statements at key execution points with step identifiers before the block where the exception may occur. Some third-party tools and PostgreSQL extensions provide enhanced tracing, but the native functionality is genuinely less detailed than Oracle's FORMAT_ERROR_BACKTRACE.
Was this helpful?
02
How do I handle Oracle's PRAGMA EXCEPTION_INIT in PostgreSQL?
PRAGMA EXCEPTION_INIT in Oracle associates a named exception with a specific ORA error number, allowing you to catch a specific error by name rather than by number. PostgreSQL handles this differently: catch exceptions by SQLSTATE code directly in the WHEN clause, or use the named exception where one exists. For example, Oracle's PRAGMA EXCEPTION_INIT(e_deadlock, -60) followed by WHEN e_deadlock THEN becomes simply WHEN deadlock_detected THEN in PostgreSQL (or WHEN SQLSTATE '40P01' THEN if you prefer SQLSTATE). For custom application errors (RAISE_APPLICATION_ERROR -20001 to -20999 in Oracle), use RAISE EXCEPTION with SQLSTATE 'P0001' in PostgreSQL and distinguish cases by the exception message or a custom SQLSTATE in the P0xxx range.
Was this helpful?
03
What is the best practice for error logging in PostgreSQL migrations from Oracle?
Create a centralized error_log table with columns for procedure_name, parameters, sqlstate, message, detail, context, constraint_name, session_user, application_name, and logged_at. Implement a log_error function that writes to this table via dblink (so entries survive transaction rollbacks) and falls back to RAISE LOG if the dblink write fails. Call this function from every EXCEPTION block before re-raising. Enable log_min_error_statement = 'error' and log_error_verbosity = 'verbose' in postgresql.conf so errors also appear in the server log. For the highest-fidelity debugging, add RAISE DEBUG statements at key execution points so the server log shows the execution trace up to the point of failure — enable session-level debug logging during incident investigation: SET client_min_messages = 'debug'.
Was this helpful?
04
Can I use PostgreSQL's INSERT ON CONFLICT to replace Oracle's DUP_VAL_ON_INDEX exception handling pattern?
Yes — and it is almost always the better approach. Oracle code that does INSERT and then catches DUP_VAL_ON_INDEX to either skip or update the conflicting row is a perfect candidate for PostgreSQL's INSERT ... ON CONFLICT DO NOTHING or INSERT ... ON CONFLICT DO UPDATE (upsert). This eliminates the exception entirely — no exception handler, no overhead, no risk of missing the exception. The SQLSTATE for unique_violation in PostgreSQL is 23505, and you can still use WHEN unique_violation THEN when the conflict handling logic is complex and cannot be expressed as an ON CONFLICT clause. But for the common pattern of 'insert if not exists, skip or update if exists', ON CONFLICT is cleaner, faster, and requires no exception handling at all.
Was this helpful?
05
How do I test that my migrated exception handlers actually fire?
Test each exception path with an intentional trigger in a controlled environment. For unique_violation: insert a duplicate row. For no_data_found: query a non-existent primary key with SELECT INTO STRICT. For check_violation: insert a value that violates a check constraint. For foreign_key_violation: insert a row referencing a non-existent parent key. For each test, verify three things: the exception is caught by the right handler (not by WHEN OTHERS as a fallback), the error_log table contains the expected entry with full context, and the re-raised exception message contains the entity ID and SQLSTATE that the caller needs. Automated migration test suites should include a 'failure injection' phase that runs all these scenarios and asserts on the log entries produced.