Migrating Oracle PL/SQL to PostgreSQL β Common Errors
- 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
- 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
- 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
- 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
No error context in logs β errors appear as single-line messages with no location or data
SELECT pid, usename, application_name, state, query_start, state_change, query FROM pg_stat_activity WHERE state != 'idle' ORDER BY query_start;tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E '(ERROR|FATAL|PANIC|CONTEXT)'Unhandled exceptions crash connections or abort transactions with no useful message
ALTER SYSTEM SET log_min_error_statement = 'error'; ALTER SYSTEM SET log_error_verbosity = 'verbose';SELECT pg_reload_conf();Batch job returns success but processed row count is zero or wrong
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';unique_violation exceptions not caught β code was catching DUP_VAL_ON_INDEX
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;Production Incident
pg_reload_conf() was called to apply the settings.Production Debug GuideSymptom β Action mapping for PostgreSQL migration issues
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: CREATE OR REPLACE PROCEDURE process_payment(p_account_id NUMBER) AS BEGIN UPDATE accounts SET balance = balance - 100 WHERE account_id = p_account_id; IF SQL%ROWCOUNT = 0 THEN RAISE NO_DATA_FOUND; END IF; EXCEPTION WHEN 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); WHEN OTHERS THEN RAISE_APPLICATION_ERROR(-20099, 'Unexpected error: ' || SQLERRM, TRUE); END; / -- POSTGRESQL EQUIVALENT: CREATE OR REPLACE FUNCTION io.thecodeforge.process_payment( p_account_id INTEGER ) RETURNS VOID AS $$ DECLARE v_affected INTEGER; v_sqlstate TEXT; v_message TEXT; v_detail TEXT; v_context TEXT; v_constraint TEXT; BEGIN UPDATE accounts SET balance = balance - 100 WHERE account_id = p_account_id; GET DIAGNOSTICS v_affected = ROW_COUNT; IF v_affected = 0 THEN RAISE EXCEPTION 'Account not found: account_id=%', p_account_id USING ERRCODE = 'P0002'; -- no_data_found in the user range END IF; EXCEPTION WHEN no_data_found THEN -- Raised above or by a SELECT INTO that returned no rows RAISE EXCEPTION 'Account not found: account_id=%', p_account_id USING ERRCODE = 'P0001'; WHEN unique_violation THEN -- PostgreSQL equivalent of Oracle's DUP_VAL_ON_INDEX GET STACKED DIAGNOSTICS v_constraint = CONSTRAINT_NAME, v_detail = PG_EXCEPTION_DETAIL; RAISE EXCEPTION 'Duplicate payment detected: account_id=%, constraint=%', p_account_id, v_constraint USING ERRCODE = 'P0001', DETAIL = v_detail; WHEN OTHERS THEN -- Catch-all: capture full context before re-raising GET STACKED DIAGNOSTICS v_sqlstate = RETURNED_SQLSTATE, v_message = MESSAGE_TEXT, v_detail = PG_EXCEPTION_DETAIL, v_context = PG_EXCEPTION_CONTEXT; -- Log to error table for post-mortem INSERT INTO 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()); RAISE EXCEPTION 'Payment processing failed: account_id=%, error=%', p_account_id, v_message USING ERRCODE = v_sqlstate, DETAIL = v_context; END; $$ LANGUAGE plpgsql;
- 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
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.
-- ============================================================ -- CENTRALIZED ERROR LOGGING INFRASTRUCTURE -- ============================================================ -- Step 1: Create the error log table CREATE TABLE IF NOT EXISTS io.thecodeforge.error_log ( log_id BIGSERIAL PRIMARY KEY, procedure_name TEXT NOT NULL, parameters TEXT, sqlstate CHAR(5), message TEXT, detail TEXT, hint TEXT, context TEXT, constraint_name TEXT, session_user TEXT DEFAULT SESSION_USER, application_name TEXT DEFAULT current_setting('application_name'), logged_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_error_log_logged_at ON io.thecodeforge.error_log(logged_at DESC); CREATE INDEX idx_error_log_procedure ON io.thecodeforge.error_log(procedure_name, logged_at DESC); -- Step 2: Centralized log function -- Note: This function runs OUTSIDE a transaction via dblink -- so it persists even when the calling transaction rolls back CREATE OR REPLACE FUNCTION io.thecodeforge.log_error( p_procedure TEXT, p_parameters TEXT, p_sqlstate TEXT, p_message TEXT, p_detail TEXT DEFAULT NULL, p_context TEXT DEFAULT NULL, p_constraint TEXT DEFAULT NULL ) RETURNS VOID AS $$ DECLARE v_conn TEXT; BEGIN -- Use dblink to write outside the current transaction -- so the log entry survives a rollback PERFORM dblink_connect( 'io_error_log', format('dbname=%s user=%s password=%s', current_database(), SESSION_USER, current_setting('app.db_password', true) ) ); PERFORM dblink_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 )); PERFORM dblink_disconnect('io_error_log'); EXCEPTION WHEN OTHERS THEN -- If the log write fails, fall back to server log -- Never let logging failure hide the original error RAISE LOG 'Error log write failed for procedure=%: sqlstate=%, message=%', p_procedure, p_sqlstate, p_message; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Step 3: Pattern for any migrated procedure CREATE OR REPLACE FUNCTION io.thecodeforge.process_order( p_order_id INTEGER ) RETURNS VOID AS $$ 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 for tracing (equivalent to Oracle's DBMS_OUTPUT or debug logging) RAISE DEBUG 'process_order: starting for order_id=%', p_order_id; -- Business logic SELECT customer_id, total_amount INTO STRICT v_customer_id, v_order_amount FROM orders WHERE order_id = p_order_id; -- STRICT makes SELECT INTO raise NO_DATA_FOUND or TOO_MANY_ROWS -- just like Oracle's SELECT INTO behavior RAISE DEBUG 'process_order: customer_id=%, amount=%', v_customer_id, v_order_amount; -- ... rest of business logic ... EXCEPTION WHEN no_data_found THEN PERFORM io.thecodeforge.log_error( 'process_order', 'order_id=' || p_order_id, 'P0002', 'Order not found', NULL, NULL, NULL ); RAISE EXCEPTION 'Order not found: order_id=%', p_order_id USING ERRCODE = 'P0002'; WHEN too_many_rows THEN PERFORM io.thecodeforge.log_error( 'process_order', 'order_id=' || p_order_id, '21000', 'Ambiguous order ID β multiple rows found', NULL, NULL, NULL ); RAISE EXCEPTION 'Ambiguous order: order_id=% matches multiple rows', p_order_id USING ERRCODE = '21000'; WHEN OTHERS THEN -- Capture the full context β this is your ORA-06512 replacement GET STACKED DIAGNOSTICS 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 RAISE EXCEPTION 'Order processing failed: order_id=%, error=%, sqlstate=%', p_order_id, v_message, v_sqlstate USING ERRCODE = v_sqlstate, DETAIL = v_context; END; $$ LANGUAGE plpgsql; -- Step 4: 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' ORDER BY logged_at DESC LIMIT 50;
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 fails CREATE OR REPLACE PROCEDURE oracle_pattern(p_account_id NUMBER) AS BEGIN -- This INSERT is part of the current transaction INSERT INTO audit_log (event, account_id, logged_at) VALUES ('PAYMENT_ATTEMPT', p_account_id, SYSDATE); SAVEPOINT before_debit; -- This UPDATE may fail UPDATE accounts SET balance = balance - 100 WHERE account_id = p_account_id; EXCEPTION WHEN OTHERS THEN ROLLBACK TO before_debit; -- Only rolls back the UPDATE -- The audit_log INSERT is preserved (still in the transaction) COMMIT; -- Commits the audit entry RAISE; -- Re-raises the original exception END; / -- ============================================================ -- POSTGRESQL EQUIVALENT: Explicit savepoint with dblink audit -- ============================================================ CREATE OR REPLACE FUNCTION io.thecodeforge.postgres_pattern( p_account_id INTEGER ) RETURNS VOID AS $$ DECLARE v_sqlstate TEXT; v_message TEXT; v_context TEXT; BEGIN -- PostgreSQL: savepoint for partial rollback SAVEPOINT before_debit; UPDATE accounts SET balance = balance - 100 WHERE account_id = p_account_id; -- If we get here, the update succeeded -- Write audit log AFTER success, inside the same transaction INSERT INTO audit_log (event, account_id, logged_at) VALUES ('PAYMENT_SUCCESS', p_account_id, NOW()); EXCEPTION WHEN OTHERS THEN -- Roll back only the UPDATE (and anything after the savepoint) ROLLBACK TO SAVEPOINT before_debit; -- Use GET STACKED DIAGNOSTICS before any further SQL GET STACKED DIAGNOSTICS 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 savepoint PERFORM io.thecodeforge.log_error( 'postgres_pattern', 'account_id=' || p_account_id, v_sqlstate, v_message, NULL, v_context, NULL ); -- Re-raise the original exception RAISE; END; $$ LANGUAGE plpgsql; -- ============================================================ -- ORACLE AUTONOMOUS TRANSACTION PATTERN -- (writing audit logs that survive rollback) -- ============================================================ -- Oracle: CREATE OR REPLACE PROCEDURE oracle_write_audit( p_event VARCHAR2, p_detail VARCHAR2 ) AS PRAGMA AUTONOMOUS_TRANSACTION; BEGIN INSERT INTO audit_log (event, detail, logged_at) VALUES (p_event, p_detail, SYSDATE); COMMIT; -- This commit does NOT affect the outer transaction END; / -- PostgreSQL equivalent via dblink: CREATE OR REPLACE FUNCTION io.thecodeforge.write_audit( p_event TEXT, p_detail TEXT ) RETURNS VOID AS $$ BEGIN -- dblink executes in a SEPARATE connection β committed immediately -- regardless of whether the calling transaction commits or rolls back PERFORM dblink_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 ) ); EXCEPTION WHEN OTHERS THEN -- Audit failure must never mask the original operation failure -- Fall back to server log if dblink write fails RAISE LOG 'Audit write failed: event=%, detail=%, error=%', p_event, p_detail, SQLERRM; END; $$ LANGUAGE plpgsql; -- ============================================================ -- PERFORMANCE: Comparing transaction patterns -- ============================================================ -- Benchmark query β run for baseline before migration EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) WITH timing AS ( SELECT now() AS start_time, count(*) AS source_rows FROM staging_orders WHERE processed = FALSE ) SELECT source_rows, now() - start_time AS planning_time FROM timing;
- 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
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.
-- ============================================================ -- ORACLE BULK COLLECT + FORALL PATTERN -- ============================================================ -- Oracle: CREATE OR REPLACE PROCEDURE oracle_bulk_process AS TYPE t_ids IS TABLE OF NUMBER; v_ids t_ids; BEGIN SELECT order_id BULK COLLECT INTO v_ids FROM staging_orders WHERE processed = 'N'; FORALL i IN 1 .. v_ids.COUNT SAVE EXCEPTIONS UPDATE orders SET status = 'PROCESSED' WHERE order_id = v_ids(i); COMMIT; EXCEPTION WHEN OTHERS THEN IF SQLCODE = -24381 THEN -- bulk errors FOR i IN 1 .. SQL%BULK_EXCEPTIONS.COUNT LOOP DBMS_OUTPUT.PUT_LINE( 'Row ' || SQL%BULK_EXCEPTIONS(i).ERROR_INDEX || ' failed: ORA-' || SQL%BULK_EXCEPTIONS(i).ERROR_CODE ); END LOOP; END IF; END; / -- ============================================================ -- POSTGRESQL EQUIVALENT: Set-based + row-level error handling -- ============================================================ CREATE OR REPLACE FUNCTION io.thecodeforge.postgres_bulk_process() RETURNS TABLE(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; GET DIAGNOSTICS v_processed = ROW_COUNT; RETURN QUERY SELECT v_processed, 0; -- Option B: Row-level loop with per-row error handling -- (equivalent to Oracle's FORALL with SAVE EXCEPTIONS) -- Uncomment this block if you need to continue on individual row failures /* FOR v_order IN SELECT order_id FROM staging_orders WHERE processed = FALSE LOOP BEGIN UPDATE orders SET status = 'PROCESSED', processed_at = NOW() WHERE order_id = v_order.order_id; v_processed := v_processed + 1; EXCEPTION WHEN OTHERS THEN GET STACKED DIAGNOSTICS 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; END LOOP; RETURN QUERY SELECT v_processed, v_failed; */ END; $$ LANGUAGE plpgsql; -- ============================================================ -- IMPLICIT CURSOR ATTRIBUTE MIGRATION -- ============================================================ -- Oracle SQL%ROWCOUNT -> PostgreSQL GET DIAGNOSTICS -- Oracle: BEGIN UPDATE accounts SET balance = balance - 100 WHERE account_id = 42; IF SQL%ROWCOUNT = 0 THEN RAISE_APPLICATION_ERROR(-20001, 'Account not found'); END IF; END; / -- PostgreSQL: DO $$ DECLARE v_count INTEGER; BEGIN UPDATE accounts SET balance = balance - 100 WHERE account_id = 42; GET DIAGNOSTICS v_count = ROW_COUNT; IF v_count = 0 THEN RAISE EXCEPTION 'Account not found: account_id=42' USING ERRCODE = 'P0002'; END IF; END; $$; -- ============================================================ -- REFCURSOR MIGRATION -- ============================================================ -- Oracle: CREATE OR REPLACE PROCEDURE get_orders( p_status IN VARCHAR2, p_cursor OUT SYS_REFCURSOR ) AS BEGIN OPEN p_cursor FOR SELECT order_id, customer_id, amount FROM orders WHERE status = p_status; END; / -- PostgreSQL: CREATE OR REPLACE FUNCTION io.thecodeforge.get_orders( p_status TEXT ) RETURNS refcursor AS $$ DECLARE v_cursor refcursor; BEGIN OPEN v_cursor FOR SELECT order_id, customer_id, amount FROM orders WHERE status = p_status; RETURN v_cursor; END; $$ LANGUAGE plpgsql;
- 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
| 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 |
| Built-in Exception Names | 60+ predefined exceptions (DUP_VAL_ON_INDEX, VALUE_ERROR, INVALID_NUMBER, etc.) | ~20 predefined exception names (NO_DATA_FOUND, TOO_MANY_ROWS, unique_violation, etc.); remaining via SQLSTATE | Medium β map Oracle names to PostgreSQL equivalents; test each handler fires with intentional failures |
| Custom Error Codes | RAISE_APPLICATION_ERROR(-20001 to -20999) β 1,000 reserved codes with custom message | RAISE EXCEPTION ... USING ERRCODE = 'P0001' β single user SQLSTATE; distinguish errors via message text or custom SQLSTATE in P0xxx range | Medium β redesign custom error code system; application-side error mapping requires updating |
| Autonomous Transactions | 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
- 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
- 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
- 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
- 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
- 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
- PostgreSQL EXCEPTION blocks carry measurable overhead β restructure code to validate before attempting rather than catch and recover, especially in high-volume batch loops
- 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
Interview Questions on This Topic
- QHow would you migrate a complex Oracle error handling stack that relies on ORA-06512 for debugging?SeniorReveal
- QWhat is the performance impact of moving from Oracle's exception handling to PostgreSQL's EXCEPTION blocks?Mid-levelReveal
- QHow do you preserve debugging context in PostgreSQL to match Oracle's ORA-06512 behavior?Mid-levelReveal
- QHow do you handle the PRAGMA AUTONOMOUS_TRANSACTION gap during an Oracle-to-PostgreSQL migration?SeniorReveal
- QWhat is the most dangerous pattern to carry from Oracle PL/SQL to PostgreSQL, and why?SeniorReveal
Frequently Asked Questions
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.
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.
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'.
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.
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.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.