Skip to content
Homeβ€Ί Databaseβ€Ί Migrating Oracle PL/SQL to PostgreSQL – Common Errors

Migrating Oracle PL/SQL to PostgreSQL – Common Errors

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: PL/SQL β†’ Topic 27 of 27
Error mapping guide (ORA-06512 equivalents in Postgres) for developers migrating codebases.
πŸ”₯ Advanced β€” solid Database foundation required
In this tutorial, you'll learn
Error mapping guide (ORA-06512 equivalents in Postgres) for developers migrating codebases.
  • 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
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • 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
🚨 START HERE
PostgreSQL Error Debugging Cheat Sheet
Immediate 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 ActionAdd 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;
tail -f /var/log/postgresql/postgresql-$(date +%Y-%m-%d).log | grep -E '(ERROR|FATAL|PANIC|CONTEXT)'
Fix NowAdd 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 ActionWrap 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 NowImplement 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 ActionAdd 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 NowAdd 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 ActionSearch 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 NowReplace 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
Production IncidentSilent Data Corruption in Batch Processing β€” Zero Rows for Three DaysA nightly ETL job migrated from Oracle to PostgreSQL completed successfully β€” exit code 0, no logged errors β€” but processed zero rows for three consecutive days before a downstream data quality check caught the anomaly.
SymptomThe 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.
AssumptionThe 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 causeThe 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.
FixFour 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 visibilityPostgreSQL requires explicit error context capture β€” nothing surfaces automatically the way ORA-06512 does β€” instrument every exception block before deploymentAdd row count assertions to batch jobs β€” if the expected count does not match the actual count, fail loudly rather than returning successTest 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 issues
Function fails with 'control reached end of function without RETURN'β†’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.
GET STACKED DIAGNOSTICS returns empty strings or NULL values→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.
Exception is caught but no line number appears in logs→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.
Performance degradation of 15 to 30 percent after adding exception blocks around loops→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.
Code that worked in Oracle raises 'invalid_transaction_state' in PostgreSQL→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.
PRAGMA AUTONOMOUS_TRANSACTION patterns produce no equivalent behavior→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.

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.

io/thecodeforge/migration/error_mapping_examples.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
-- ============================================================
-- 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;
Mental Model
Error Translation Mindset
Think in exception semantics, not error numbers β€” the SQLSTATE class tells you the category, the name tells you the specific failure.
  • 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.

io/thecodeforge/migration/error_context_preservation.sql Β· PLPGSQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
-- ============================================================
-- 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;
⚠ Context Loss Warning
πŸ“Š 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.

io/thecodeforge/migration/transaction_control.sql Β· SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
-- ============================================================
-- 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;
πŸ’‘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.sql Β· PLPGSQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
-- ============================================================
-- 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;
Mental Model
Bulk Processing Migration Strategy
Oracle's FORALL is a bulk DML mechanism β€” in PostgreSQL, set-based SQL (UPDATE ... FROM, INSERT ... SELECT) is the direct equivalent and is faster.
  • 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.
πŸ—‚ Oracle vs PostgreSQL Error Handling Comparison
Key differences affecting migration strategy, instrumentation effort, and production readiness
AspectOracle PL/SQLPostgreSQL PL/pgSQLMigration Impact
Error Stack (call chain + line numbers)Automatic ORA-06512 with schema, package, procedure, and line number on every unhandled exceptionManual capture via GET STACKED DIAGNOSTICS inside EXCEPTION block β€” PG_EXCEPTION_CONTEXT gives function names but not always line numbersHigh β€” requires explicit instrumentation in every exception block before go-live
Transaction Control on ExceptionException does not automatically roll back the transaction β€” handler can commit partial workUnhandled exception aborts the transaction β€” EXCEPTION block uses implicit subtransaction; handler can continue the outer transactionMedium β€” review every exception handler that commits partial work; test rollback semantics explicitly
Built-in Exception Names60+ 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 SQLSTATEMedium β€” map Oracle names to PostgreSQL equivalents; test each handler fires with intentional failures
Custom Error CodesRAISE_APPLICATION_ERROR(-20001 to -20999) β€” 1,000 reserved codes with custom messageRAISE EXCEPTION ... USING ERRCODE = 'P0001' β€” single user SQLSTATE; distinguish errors via message text or custom SQLSTATE in P0xxx rangeMedium β€” redesign custom error code system; application-side error mapping requires updating
Autonomous TransactionsPRAGMA AUTONOMOUS_TRANSACTION β€” native, zero configuration, widely used for audit loggingNo native equivalent β€” requires dblink extension or pg_background; connection overhead appliesHigh β€” audit logging patterns require architectural redesign before migration
Bulk DML Error HandlingFORALL with SAVE EXCEPTIONS β€” continues on row failures, collects all errors in SQL%BULK_EXCEPTIONSFOR loop with inner EXCEPTION block β€” functional equivalent but significantly slower for large volumesMedium to High β€” assess whether per-row error tolerance is required or set-based SQL suffices
Performance of Exception BlocksException handling overhead is minimal and optimizedEach EXCEPTION block creates an implicit subtransaction β€” roughly 2ms per exception raised; excessive use in loops degrades throughputHigh β€” restructure loops to validate before attempting rather than catch and recover
Post-Mortem Debugging ContextFull call stack in ORA-06512 frames; DBMS_UTILITY.FORMAT_ERROR_BACKTRACE availableGET STACKED DIAGNOSTICS provides SQLSTATE, message, and function chain; no built-in equivalent to FORMAT_ERROR_BACKTRACEHigh β€” 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

    βœ•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 Questions on This Topic

  • QHow would you migrate a complex Oracle error handling stack that relies on ORA-06512 for debugging?SeniorReveal
    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.
  • QWhat is the performance impact of moving from Oracle's exception handling to PostgreSQL's EXCEPTION blocks?Mid-levelReveal
    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.
  • QHow do you preserve debugging context in PostgreSQL to match Oracle's ORA-06512 behavior?Mid-levelReveal
    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.
  • QHow do you handle the PRAGMA AUTONOMOUS_TRANSACTION gap during an Oracle-to-PostgreSQL migration?SeniorReveal
    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.
  • QWhat is the most dangerous pattern to carry from Oracle PL/SQL to PostgreSQL, and why?SeniorReveal
    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'.

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.

πŸ”₯
Naren Founder & Author

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.

← PreviousOracle 19c vs 21c vs 23ai: Changes in Error Handling & PL/SQL
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged