PostgreSQL Exception PostgreSQL · Exceptions · Handling

PL/pgSQL Exception Handling

Master PL/pgSQL exception handling — BEGIN/EXCEPTION/WHEN syntax, NO_DATA_FOUND and TOO_MANY_ROWS via SELECT INTO STRICT, the FOUND special variable, and SQLERRM/SQLSTATE inspection. Critical fixes: original INSERT had duplicate primary key rows, INTO STRICT used an undeclared variable, and table name mismatched between create and reference.

An exception is what PL/pgSQL raises when something goes wrong during execution: a query that returns no rows when the code expected exactly one, an integer overflow, a division by zero, a unique-constraint violation, a null value where one isn't allowed. By default, an exception aborts the current block (and the surrounding transaction).

You can catch exceptions and respond gracefully using an exception handler — a section of the block that runs only when something goes wrong. The pattern is borrowed from procedural languages like Ada and Oracle PL/SQL, but PostgreSQL has its own set of named conditions and SQLSTATE codes.

Source of exceptionsExamples
Internally raised (by the SQL engine)division_by_zero, unique_violation, not_null_violation
Raised by SELECT INTO STRICTno_data_found, too_many_rows
Raised explicitly (by your code)Anything you raise via RAISE EXCEPTION
BEGIN -- main body ... EXCEPTION WHEN exception_name_1 THEN handler_1; WHEN exception_name_2 OR exception_name_3 THEN handler_2; WHEN OTHERS THEN catch_all_handler; END;

Three things to know:

  • Exception handlers are checked top-to-bottom; the first matching WHEN wins.
  • WHEN OTHERS catches anything not matched above. Place it last.
  • An exception handler can also reference exceptions by SQLSTATE: WHEN SQLSTATE 'P0002' THEN.
⚠️ When an exception is raised inside a block, any DML statements executed in that block are rolled back as part of an implicit savepoint. The handler runs against the state from before the block. This is different from how some languages work — PostgreSQL gives you transactional safety here without you having to manage it.
PL/pgSQL — Setup
DROP TABLE IF EXISTS customer;

CREATE TABLE customer (
    customer_id INT PRIMARY KEY,
    name        TEXT
);

INSERT INTO customer (customer_id, name) VALUES
    (1, 'John Doe'),
    (2, 'Jane Smith'),
    (3, 'Alice Johnson'),
    (4, 'Mark Johnson'),
    (5, 'Emily Brown'),
    (6, 'David Smith');
⚠️ The original tutorial's INSERT had a hard error — it tried to insert three rows with customer_id = 1, but customer_id is the PRIMARY KEY. The first duplicate would raise unique_violation and abort the entire INSERT, leaving partial state. Fixed: each customer_id is unique. Original page also referred to a table called cust in some examples, while the table was created as Customer; consistent naming below.
Example 1 — The Default: Silent NULL on No Match

A plain SELECT INTO doesn't raise an exception when no row is found — it just leaves the target variable as NULL. Look at this:

PL/pgSQL — silent NULL
DO $$
DECLARE
    l_name        TEXT;
    l_customer_id INT := 999;            -- no such customer
BEGIN
    SELECT name INTO l_name
    FROM   customer
    WHERE  customer_id = l_customer_id;

    RAISE NOTICE 'Customer name is: %', l_name;
END
$$;
OUTPUT
psql — silent NULL
postgres=#DO $$ DECLARE l_name TEXT; l_customer_id INT := 999; BEGIN SELECT name INTO l_name FROM customer WHERE customer_id = l_customer_id; RAISE NOTICE 'Customer name is: %', l_name; END $$;
NOTICE: Customer name is: <NULL>
DO

No exception, just a NULL — easy to miss in production code. Two ways to detect this case explicitly:

  1. Check for NULL after the SELECT with an IF l_name IS NULL THEN
  2. Use SELECT INTO STRICT — raises no_data_found when zero rows return, too_many_rows when more than one
Example 2 — Catching no_data_found with STRICT

SELECT INTO STRICT requires exactly one row. Anything else raises an exception you can catch:

PL/pgSQL — STRICT with handler
DO $$
DECLARE
    l_name        TEXT;
    l_customer_id INT := 999;
BEGIN
    SELECT name INTO STRICT l_name
    FROM   customer
    WHERE  customer_id = l_customer_id;

    RAISE NOTICE 'Customer name is: %', l_name;
EXCEPTION
    WHEN NO_DATA_FOUND THEN
        RAISE NOTICE 'Customer % does not exist', l_customer_id;
    WHEN TOO_MANY_ROWS THEN
        RAISE NOTICE 'More than one customer matches % — data integrity issue?',
                     l_customer_id;
END
$$;
OUTPUT
psql — STRICT
postgres=#DO $$ ... SELECT name INTO STRICT l_name FROM customer WHERE customer_id = 999; ... EXCEPTION WHEN NO_DATA_FOUND THEN ... END $$;
NOTICE: Customer 999 does not exist
DO

Now we get a clear "does not exist" message. The exception was raised, caught, handled, and execution continued past the block.

⚠️ The original tutorial had a serious bug here — it wrote SELECT film_id, title INTO STRICT rec l_name FROM film WHERE .... The variable list rec l_name mixed two undeclared variables, of which only rec was actually declared. The function would fail with "rec l_name is not a known variable". Fixed above with proper STRICT syntax: one variable target.
Example 3 — Catching division_by_zero

An internally raised exception. The SQL engine raises division_by_zero when math goes south:

PL/pgSQL — division_by_zero
DO $$
DECLARE
    a NUMERIC := 100;
    b NUMERIC := 0;
    result NUMERIC;
BEGIN
    result := a / b;                       -- raises division_by_zero
    RAISE NOTICE 'Result: %', result;
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Cannot divide by zero — using NULL instead';
        result := NULL;
END
$$;
OUTPUT
psql — div by zero
postgres=#DO $$ DECLARE a NUMERIC := 100; b NUMERIC := 0; result NUMERIC; BEGIN result := a / b; ... EXCEPTION WHEN division_by_zero THEN ... END $$;
NOTICE: Cannot divide by zero — using NULL instead
DO
Example 4 — The FOUND Variable

An alternative to STRICT for the "no rows" case: do a non-STRICT SELECT, then check the special FOUND variable. FOUND is automatically set to TRUE when the last query returned at least one row, FALSE otherwise:

PL/pgSQL — FOUND check
DO $$
DECLARE
    l_name        TEXT;
    l_customer_id INT := 1;
BEGIN
    SELECT name INTO l_name
    FROM   customer
    WHERE  customer_id = l_customer_id;

    IF FOUND THEN
        RAISE NOTICE 'Customer found: %', l_name;
    ELSE
        RAISE NOTICE 'No customer with ID %', l_customer_id;
    END IF;
END
$$;
OUTPUT
psql — FOUND
postgres=#DO $$ DECLARE l_name TEXT; l_customer_id INT := 1; BEGIN SELECT name INTO l_name FROM customer WHERE customer_id = l_customer_id; IF FOUND THEN ... END $$;
NOTICE: Customer found: John Doe
DO
ApproachProsCons
SELECT INTO + IS NULL checkSimple, no exception overheadWon't distinguish "no rows" from "row with NULL value"
SELECT INTO + FOUND checkDistinguishes the two casesFOUND is shared across multiple queries — easy to misread
SELECT INTO STRICT + handlerClear intent; also catches "more than one row"More boilerplate; exception handling has a small cost
Example 5 — Catching unique_violation

A common internally-raised exception: trying to INSERT a row whose primary key (or other UNIQUE column) already exists. The SQL engine raises unique_violation:

PL/pgSQL — unique_violation
DO $$
BEGIN
    INSERT INTO customer (customer_id, name)
    VALUES (1, 'Duplicate John');           -- 1 already exists
    RAISE NOTICE 'Inserted successfully';
EXCEPTION
    WHEN unique_violation THEN
        RAISE NOTICE 'Customer ID already exists — skipping';
END
$$;
OUTPUT
psql — duplicate
postgres=#DO $$ BEGIN INSERT INTO customer (customer_id, name) VALUES (1, 'Duplicate John'); ... EXCEPTION WHEN unique_violation THEN ... END $$;
NOTICE: Customer ID already exists — skipping
DO
💡 For "insert if not exists," prefer INSERT ... ON CONFLICT DO NOTHING. Cleaner than catching the exception, and faster (no exception-handling overhead). Reach for the catch-the-exception pattern only when you need different logic per error type.
NameSQLSTATEWhen raised
no_data_foundP0002SELECT INTO STRICT returned zero rows
too_many_rowsP0003SELECT INTO STRICT returned more than one row
division_by_zero22012Mathematical divide by zero
numeric_value_out_of_range22003Integer overflow, etc.
not_null_violation23502NULL into a NOT NULL column
unique_violation23505UNIQUE / PRIMARY KEY constraint violated
foreign_key_violation23503FOREIGN KEY constraint violated
check_violation23514CHECK constraint violated
case_not_found20000CASE statement reached no branch and had no ELSE
raise_exceptionP0001RAISE EXCEPTION without ERRCODE specified

You can reference any of these by name in WHEN clauses, or by SQLSTATE: WHEN SQLSTATE '23505' THEN. Use names when they exist; SQLSTATE for codes that don't have a friendly name.

Inside an exception handler, two special variables tell you what just happened:

  • SQLERRM — the human-readable error message text
  • SQLSTATE — the 5-character SQLSTATE code (e.g. '23505')
PL/pgSQL — SQLERRM in WHEN OTHERS
DO $$
BEGIN
    INSERT INTO customer (customer_id, name) VALUES (1, NULL);  -- name is NOT NULL
EXCEPTION
    WHEN OTHERS THEN
        RAISE NOTICE 'Caught error: %', SQLERRM;
        RAISE NOTICE 'SQLSTATE was: %', SQLSTATE;
END
$$;
OUTPUT
psql — SQLERRM
postgres=#DO $$ BEGIN INSERT INTO customer (customer_id, name) VALUES (1, NULL); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'Caught error: %', SQLERRM; RAISE NOTICE 'SQLSTATE was: %', SQLSTATE; END $$;
NOTICE: Caught error: null value in column "name" of relation "customer" violates not-null constraint
NOTICE: SQLSTATE was: 23502
DO
  1. Catch specific exceptions, not OTHERS, when you can. A specific handler shows readers what failure modes you've considered. WHEN OTHERS is a catch-all — handy as a last resort, but it can swallow bugs you'd rather see raised.
  2. Use SELECT INTO STRICT when you mean "exactly one row." Catches both "no rows" and "more than one" cases — better than relying on FOUND or NULL checks.
  3. Don't use exceptions for normal control flow. Catching an exception costs more than checking with IF. For the "if exists, update; else insert" pattern, prefer INSERT ... ON CONFLICT over a try-insert-catch-then-update loop.
  4. Reraise unknown exceptions in WHEN OTHERS handlers. If you can't actually handle an exception, log what you can and use a bare RAISE; to let it propagate. Don't silently swallow.
  5. Prefer named exceptions to SQLSTATE when a name exists — more readable. Use SQLSTATE for codes without friendly names.
  6. Be aware that the entire block's effects are rolled back when an exception occurs. If you've INSERTed and UPDATEd before the failing line, those are undone before the handler runs.
  • An exception handler section in a PL/pgSQL block lets you catch errors and respond gracefully instead of letting them abort the transaction.
  • The syntax is BEGIN ... EXCEPTION WHEN exception_name THEN handler ... END.
  • Use SELECT INTO STRICT when you want "exactly one row" — raises no_data_found or too_many_rows on mismatch.
  • Plain SELECT INTO sets the variable to NULL on no match — combine with FOUND or an explicit NULL check.
  • Use named exceptions (like division_by_zero, unique_violation) when they exist; use SQLSTATE for codes without names.
  • Inside a handler, SQLERRM gives the message text; SQLSTATE gives the 5-character code.
  • Don't use exceptions for normal control flow — they have a cost. WHEN OTHERS is a last-resort catch-all.