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 exceptions | Examples |
|---|---|
| Internally raised (by the SQL engine) | division_by_zero, unique_violation, not_null_violation |
| Raised by SELECT INTO STRICT | no_data_found, too_many_rows |
| Raised explicitly (by your code) | Anything you raise via RAISE EXCEPTION |
Three things to know:
- Exception handlers are checked top-to-bottom; the first matching
WHENwins. WHEN OTHERScatches anything not matched above. Place it last.- An exception handler can also reference exceptions by SQLSTATE:
WHEN SQLSTATE 'P0002' THEN.
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');
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.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:
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
$$;
No exception, just a NULL — easy to miss in production code. Two ways to detect this case explicitly:
- Check for NULL after the SELECT with an
IF l_name IS NULL THEN - Use
SELECT INTO STRICT— raisesno_data_foundwhen zero rows return,too_many_rowswhen more than one
SELECT INTO STRICT requires exactly one row. Anything else raises an exception you can catch:
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
$$;
Now we get a clear "does not exist" message. The exception was raised, caught, handled, and execution continued past the block.
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.An internally raised exception. The SQL engine raises division_by_zero when math goes south:
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
$$;
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:
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
$$;
| Approach | Pros | Cons |
|---|---|---|
| SELECT INTO + IS NULL check | Simple, no exception overhead | Won't distinguish "no rows" from "row with NULL value" |
| SELECT INTO + FOUND check | Distinguishes the two cases | FOUND is shared across multiple queries — easy to misread |
| SELECT INTO STRICT + handler | Clear intent; also catches "more than one row" | More boilerplate; exception handling has a small cost |
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:
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
$$;
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.| Name | SQLSTATE | When raised |
|---|---|---|
no_data_found | P0002 | SELECT INTO STRICT returned zero rows |
too_many_rows | P0003 | SELECT INTO STRICT returned more than one row |
division_by_zero | 22012 | Mathematical divide by zero |
numeric_value_out_of_range | 22003 | Integer overflow, etc. |
not_null_violation | 23502 | NULL into a NOT NULL column |
unique_violation | 23505 | UNIQUE / PRIMARY KEY constraint violated |
foreign_key_violation | 23503 | FOREIGN KEY constraint violated |
check_violation | 23514 | CHECK constraint violated |
case_not_found | 20000 | CASE statement reached no branch and had no ELSE |
raise_exception | P0001 | RAISE 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 textSQLSTATE— the 5-character SQLSTATE code (e.g.'23505')
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
$$;
- Catch specific exceptions, not
OTHERS, when you can. A specific handler shows readers what failure modes you've considered.WHEN OTHERSis a catch-all — handy as a last resort, but it can swallow bugs you'd rather see raised. - Use
SELECT INTO STRICTwhen you mean "exactly one row." Catches both "no rows" and "more than one" cases — better than relying on FOUND or NULL checks. - 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 CONFLICTover a try-insert-catch-then-update loop. - Reraise unknown exceptions in
WHEN OTHERShandlers. If you can't actually handle an exception, log what you can and use a bareRAISE;to let it propagate. Don't silently swallow. - Prefer named exceptions to SQLSTATE when a name exists — more readable. Use SQLSTATE for codes without friendly names.
- 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 STRICTwhen you want "exactly one row" — raisesno_data_foundortoo_many_rowson mismatch. - Plain
SELECT INTOsets the variable to NULL on no match — combine withFOUNDor 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,
SQLERRMgives the message text;SQLSTATEgives the 5-character code. - Don't use exceptions for normal control flow — they have a cost.
WHEN OTHERSis a last-resort catch-all.