How to Use the PL/pgSQL Language in PostgreSQL

How to Use the PL/pgSQL Language in PostgreSQL

If I had to pick one skill that separates someone who merely queries PostgreSQL from someone who actually builds on top of it, it would be a solid working knowledge of PL/pgSQL. It’s the default procedural language for PostgreSQL, installed automatically with every standard installation, and it’s the language behind the vast majority of stored procedures, trigger functions, and custom logic I encounter in real production databases. Unlike PL/Python or PL/Perl, I don’t need to install any extra packages or worry about untrusted-language security concerns — it’s there from the moment PostgreSQL starts.

In this article, I’ll walk through what PL/pgSQL is, its core syntax, how to declare variables and parameters, control-flow structures, error handling, practical real-world examples, common troubleshooting scenarios, and the best practices I rely on for writing clean, maintainable PL/pgSQL code.

What Is PL/pgSQL?

PL/pgSQL stands for “Procedural Language/PostgreSQL,” and it’s a procedural extension to standard SQL, purpose-built for PostgreSQL. It adds the control structures that plain SQL lacks — variables, loops, conditionals, exception handling — while staying tightly integrated with SQL syntax and PostgreSQL’s type system.

Because it’s compiled and cached per session the first time a function is called, PL/pgSQL functions typically run faster than equivalent logic implemented in an untrusted external-language procedural extension, since there’s no interpreter startup cost or cross-language marshaling overhead involved.

It’s automatically available in every PostgreSQL database by default in modern versions, so I don’t need a CREATE EXTENSION step in most cases — though it’s still technically registered as a language and can be verified with:

SELECT lanname FROM pg_language WHERE lanname = 'plpgsql';

Why PL/pgSQL Is Usually My Default Choice

  • No installation required. It’s there out of the box, which matters a lot for portability across environments and hosting providers, including managed database services that don’t allow installing arbitrary extensions.
  • Tight SQL integration. Since it’s designed specifically for PostgreSQL, working with query results, cursors, and PostgreSQL-specific types feels completely native.
  • Performance. For SQL-heavy logic, PL/pgSQL typically performs faster than PL/Python or PL/Perl equivalents because there’s no interpreter marshaling overhead between two different type systems.
  • Broad familiarity. Given how long it’s been the default, most PostgreSQL DBAs and backend developers already know at least the basics, which reduces onboarding friction on a team.

Basic Function Syntax

A basic PL/pgSQL function looks like this:

CREATE OR REPLACE FUNCTION add_numbers(a INTEGER, b INTEGER)
RETURNS INTEGER
AS $$
BEGIN
    RETURN a + b;
END;
$$ LANGUAGE plpgsql;
SELECT add_numbers(3, 5);
-- 8

Every PL/pgSQL function body is wrapped in a BEGIN ... END block, and the language is specified with LANGUAGE plpgsql. The dollar-quoting ($$) avoids the need to escape single quotes inside the function body, which becomes especially useful once the logic includes string literals.

Declaring Variables

Variables are declared in an optional DECLARE section before BEGIN:

CREATE OR REPLACE FUNCTION calculate_discount(price NUMERIC, discount_percent NUMERIC)
RETURNS NUMERIC
AS $$
DECLARE
    discount_amount NUMERIC;
    final_price NUMERIC;
BEGIN
    discount_amount := price * (discount_percent / 100);
    final_price := price - discount_amount;
    RETURN final_price;
END;
$$ LANGUAGE plpgsql;
SELECT calculate_discount(200, 15);
-- 170.00

I can also declare variables using %TYPE to inherit a column’s data type automatically, which keeps functions in sync if the underlying column type changes later:

DECLARE
    user_email users.email%TYPE;

Control Flow: Conditionals and Loops

IF / ELSIF / ELSE

CREATE OR REPLACE FUNCTION classify_age(age INTEGER)
RETURNS TEXT
AS $$
BEGIN
    IF age < 13 THEN
        RETURN 'child';
    ELSIF age < 20 THEN
        RETURN 'teenager';
    ELSIF age < 65 THEN
        RETURN 'adult';
    ELSE
        RETURN 'senior';
    END IF;
END;
$$ LANGUAGE plpgsql;

LOOP, WHILE, and FOR

CREATE OR REPLACE FUNCTION sum_to_n(n INTEGER)
RETURNS INTEGER
AS $$
DECLARE
    total INTEGER := 0;
    i INTEGER;
BEGIN
    FOR i IN 1..n LOOP
        total := total + i;
    END LOOP;
    RETURN total;
END;
$$ LANGUAGE plpgsql;
SELECT sum_to_n(10);
-- 55

I also frequently use FOR ... IN SELECT ... to iterate over query results directly, which is one of PL/pgSQL’s most useful patterns:

CREATE OR REPLACE FUNCTION list_active_user_emails()
RETURNS SETOF TEXT
AS $$
DECLARE
    r RECORD;
BEGIN
    FOR r IN SELECT email FROM users WHERE is_active = true LOOP
        RETURN NEXT r.email;
    END LOOP;
    RETURN;
END;
$$ LANGUAGE plpgsql;

Returning Different Result Types

RETURNS TABLE

CREATE OR REPLACE FUNCTION get_top_customers(min_orders INTEGER)
RETURNS TABLE(customer_id INTEGER, order_count BIGINT)
AS $$
BEGIN
    RETURN QUERY
    SELECT c.id, COUNT(o.id)
    FROM customers c
    JOIN orders o ON o.customer_id = c.id
    GROUP BY c.id
    HAVING COUNT(o.id) >= min_orders;
END;
$$ LANGUAGE plpgsql;
SELECT * FROM get_top_customers(5);

RETURNS SETOF with RETURN NEXT

Shown above in the email listing example — RETURN NEXT accumulates rows one at a time inside a loop, followed by a final bare RETURN to signal completion.

Exception Handling

PL/pgSQL supports structured exception handling using BEGIN ... EXCEPTION ... END blocks:

CREATE OR REPLACE FUNCTION safe_divide(numerator NUMERIC, denominator NUMERIC)
RETURNS NUMERIC
AS $$
BEGIN
    RETURN numerator / denominator;
EXCEPTION
    WHEN division_by_zero THEN
        RAISE NOTICE 'Division by zero attempted, returning NULL';
        RETURN NULL;
END;
$$ LANGUAGE plpgsql;
SELECT safe_divide(10, 0);
-- NOTICE: Division by zero attempted, returning NULL
-- NULL

I can also catch general exceptions and inspect error details using SQLERRM and SQLSTATE:

CREATE OR REPLACE FUNCTION insert_user_safe(p_email TEXT)
RETURNS TEXT
AS $$
BEGIN
    INSERT INTO users(email) VALUES (p_email);
    RETURN 'success';
EXCEPTION
    WHEN unique_violation THEN
        RETURN 'error: email already exists';
    WHEN OTHERS THEN
        RETURN 'error: ' || SQLERRM;
END;
$$ LANGUAGE plpgsql;

Writing Trigger Functions in PL/pgSQL

Trigger functions are one of PL/pgSQL’s most common real-world uses. Special variables NEW and OLD represent the row being inserted/updated and the previous row’s values, respectively:

CREATE OR REPLACE FUNCTION normalize_email()
RETURNS trigger
AS $$
BEGIN
    NEW.email := lower(trim(NEW.email));
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_normalize_email
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION normalize_email();

Here’s another common pattern — automatically maintaining an updated_at timestamp column:

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger
AS $$
BEGIN
    NEW.updated_at := now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();

Dynamic SQL with EXECUTE

For cases where a query needs to be built dynamically at runtime — for example, based on a variable table or column name — I use the EXECUTE statement along with format() for safe identifier and value substitution:

CREATE OR REPLACE FUNCTION count_rows(table_name TEXT)
RETURNS BIGINT
AS $$
DECLARE
    row_count BIGINT;
BEGIN
    EXECUTE format('SELECT COUNT(*) FROM %I', table_name) INTO row_count;
    RETURN row_count;
END;
$$ LANGUAGE plpgsql;
SELECT count_rows('orders');

I use %I for identifiers (table and column names) and %L for literal values inside format(), since this properly escapes both and protects against SQL injection when the input is dynamic.

Common Use Cases

PL/pgSQL shows up constantly in real PostgreSQL systems for:

  1. Audit and timestamp triggers — automatically tracking created/updated timestamps and change history.
  2. Data validation logic — enforcing complex business rules that go beyond what CHECK constraints can express.
  3. Stored procedures for multi-step transactions — such as processing an order that needs to update inventory, create a shipment record, and log an audit entry atomically.
  4. Custom aggregate state transitions — the transition functions behind CREATE AGGREGATE are almost always written in PL/pgSQL.
  5. Materialized view refresh helpers and other scheduled maintenance routines invoked via pg_cron or similar.

Troubleshooting Common Issues

“control reached end of function without RETURN” This means a code path exists where the function doesn’t explicitly return a value. I check every branch of IF/ELSIF/ELSE logic to make sure each one has a RETURN statement, or add a fallback RETURN at the very end of the function.

Ambiguous column reference errors This happens often when a variable name matches a column name referenced in an embedded SQL statement inside the function. I either rename the variable (a common convention is prefixing local variables with v_ or p_ for parameters) or qualify the column reference with the table name explicitly.

Trigger appears to run but changes aren’t applied For BEFORE row-level triggers, I make sure the function actually returns NEW (with any modifications applied to it) — forgetting this, or accidentally returning OLD, silently discards intended changes.

Function runs slowly on large datasets Row-by-row procedural loops over large result sets are often much slower than an equivalent set-based SQL statement. Whenever I catch myself writing a FOR ... IN SELECT ... LOOP that does simple aggregation or transformation, I check whether the same logic can be expressed as a single SQL statement instead — set-based operations in PostgreSQL almost always outperform explicit iteration.

“cached plan must not change result type” errors after altering a function’s return type PostgreSQL caches query plans referencing functions, and changing a function’s signature or return type sometimes conflicts with already-planned statements in a long-running session. Reconnecting the session, or explicitly using DROP FUNCTION followed by CREATE FUNCTION instead of only CREATE OR REPLACE FUNCTION, usually resolves this.

Best Practices

  • Prefix parameters and variables consistently. I use a convention like p_ for parameters and v_ for local variables to avoid ambiguous column reference errors when they share names with table columns.
  • Prefer set-based SQL over explicit loops when possible. PL/pgSQL loops are a tool for genuinely procedural logic, not a substitute for a well-written UPDATE ... FROM or aggregate query.
  • Always use format() with %I/%L for dynamic SQL. Never build a dynamic query string through direct concatenation with untrusted input.
  • Handle exceptions deliberately, not broadly. Catching WHEN OTHERS everywhere can mask real bugs; I catch specific exception conditions where possible and let unexpected errors propagate so they’re visible.
  • Keep functions focused on one responsibility. A function that validates input, writes to three tables, and sends a notification is hard to test and debug — I break large procedures into smaller composable functions where it makes sense.
  • Comment non-obvious business logic. PL/pgSQL functions often encode important business rules; a short comment explaining why a rule exists saves the next person (often me, six months later) significant time.
  • Version control every function definition. I keep all CREATE OR REPLACE FUNCTION statements in migration files tracked in source control, rather than editing functions ad hoc directly against production.
  • Test with EXPLAIN ANALYZE when performance matters. For functions wrapping complex queries, I check the query plan the same way I would for any hand-written SQL statement.

Cursors and Fine-Grained Row Processing

For cases where I need more control over row-by-row processing than a simple FOR ... IN SELECT loop offers — for example, processing an extremely large result set without holding it entirely in memory — PL/pgSQL supports explicit cursors:

CREATE OR REPLACE FUNCTION process_large_orders_batch()
RETURNS VOID
AS $$
DECLARE
    order_cursor CURSOR FOR SELECT id, amount FROM orders WHERE status = 'pending';
    order_record RECORD;
BEGIN
    OPEN order_cursor;
    LOOP
        FETCH order_cursor INTO order_record;
        EXIT WHEN NOT FOUND;

        UPDATE orders SET status = 'processed' WHERE id = order_record.id;
    END LOOP;
    CLOSE order_cursor;
END;
$$ LANGUAGE plpgsql;

I reach for explicit cursors specifically when I need fine control over fetch batching, or when working with a function that needs to hold a cursor open across multiple calls (an advanced pattern usually only needed for specific procedural workflows).

Comparing PL/pgSQL to Other Procedural Languages

Against PL/Python and PL/Perl, PL/pgSQL wins decisively on performance for SQL-centric logic and requires zero additional installation, since it ships with every standard PostgreSQL install. It loses ground when the logic genuinely needs a rich external library ecosystem — statistical computation, advanced text processing, or reusing existing application-layer code written in another language.

Against PL/Java, PL/pgSQL is far lighter weight, with no JVM startup cost and no additional memory overhead per connection, but it can’t match PL/Java’s ability to directly reuse a large existing Java codebase.

For the significant majority of stored procedure and trigger logic I write day to day — validation rules, audit trails, calculated fields, and multi-step transactional operations — PL/pgSQL remains the right default, with the other procedural languages reserved for cases with a specific, well-justified need.

Security Considerations

PL/pgSQL functions run with the privileges of either the invoking user (SECURITY INVOKER, the default) or the function’s owner (SECURITY DEFINER), and understanding this distinction matters a great deal for security.

SECURITY DEFINER functions need careful search_path handling. A SECURITY DEFINER function runs with the privileges of whoever created it, regardless of who calls it — which is powerful for controlled privilege escalation (like letting a low-privilege role perform a specific administrative action safely) but dangerous if the function doesn’t pin its search_path explicitly:

CREATE OR REPLACE FUNCTION admin_reset_password(user_id INTEGER)
RETURNS VOID
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
BEGIN
    -- privileged logic here
END;
$$ LANGUAGE plpgsql;

Without the explicit SET search_path, a malicious user with schema-creation privileges could potentially plant objects earlier in the resolution path and hijack the function’s unqualified references, executing arbitrary logic with the function owner’s privileges.

Dynamic SQL remains the primary injection risk. As covered earlier, EXECUTE format('...', %I, %L) is the safe pattern for dynamic identifiers and literals; raw string concatenation with untrusted input is never acceptable, regardless of how trusted PL/pgSQL feels as “native” PostgreSQL syntax.

Frequently Asked Questions

Can PL/pgSQL functions call functions written in other procedural languages? Yes — once a function exists in the database, regardless of its implementation language, it can be called from any other function, including PL/pgSQL, exactly like a native SQL function.

What’s the difference between RETURN and RETURN NEXT? RETURN exits the function immediately with a single value (or ends a SETOF function after all rows have been emitted via RETURN NEXT). RETURN NEXT adds one row to the result set being built up during a SETOF-returning function, without exiting the function.

How do I debug a PL/pgSQL function step by step? I use RAISE NOTICE liberally at key points during development to trace variable values and control flow, then remove or downgrade those statements to RAISE DEBUG before deploying to production, keeping the diagnostic capability available without cluttering normal client output.

A Note on Readability at Scale

As PL/pgSQL functions grow beyond a handful of lines, I’ve found a few habits keep them readable years later. I keep the DECLARE block alphabetized or grouped by purpose so it’s easy to scan. I add a short comment block above the function explaining its purpose, expected callers, and any non-obvious assumptions about input data. And for functions with several distinct responsibilities — validate, then transform, then persist — I split them into smaller helper functions rather than one long monolithic block, even if that means a slightly higher number of CREATE FUNCTION statements in the migration file. This mirrors the same discipline I’d apply to any other codebase, and PostgreSQL doesn’t penalize me for organizing logic this way.

Final Thoughts

PL/pgSQL is the backbone of procedural logic in PostgreSQL, and for the overwhelming majority of stored procedures, trigger functions, and custom validation logic I write, it’s my default choice — not because the alternatives aren’t capable, but because it’s fast, always available, and deeply integrated with PostgreSQL’s query engine and type system. Getting comfortable with its control-flow syntax, exception handling, and dynamic SQL patterns pays off almost immediately, since so much of real-world PostgreSQL administration and application development eventually touches a PL/pgSQL function somewhere in the stack.

Total
1
Shares

Leave a Reply

Previous Post
How to Create Custom Operators in PostgreSQL

How to Create Custom Operators in PostgreSQL

Next Post
How to Use the PL/Python Language in PostgreSQL

How to Use the PL/Python Language in PostgreSQL

Related Posts