← PostgreSQL DBA← PostgreSQL DBA
PostgreSQL DBAPostgreSQL DBA19 Th7, 2026Jul 19, 202624 phút đọc20 min read

Procedure, Function & TriggerProcedures, Functions & Triggers

Thuộc bộ kiến thức PostgreSQL DBA Roadmap.

Tổng quan

PostgreSQL cho phép đưa logic xử lý vào ngay trong database, thay vì (hoặc bên cạnh) tầng application. Có ba khối xây dựng chính giúp làm điều này: function, trả về một giá trị và có thể nhúng thẳng vào SELECT, WHERE, hay JOIN; procedure (thêm từ PostgreSQL 11), chạy một chuỗi bước và — khác với function — có thể tự quản lý transaction của chính nó bằng COMMIT/ROLLBACK; và trigger, gắn một function vào một table để tự động kích hoạt khi có INSERT/UPDATE/DELETE/TRUNCATE.

Cả ba đều thường được viết bằng PL/pgSQL, ngôn ngữ thủ tục (procedural language) built-in của PostgreSQL — về bản chất là SQL được bọc thêm biến, câu lệnh điều kiện, vòng lặp, và xử lý ngoại lệ. Hiểu PL/pgSQL là điều kiện tiên quyết cho mọi thứ còn lại trong bài này, nên chúng ta sẽ bắt đầu từ đó trước khi đi vào function, procedure, volatility và trigger.

Bài viết này giả định bạn đã quen với các data type và schema object cơ bản (xem ./03-data-types-and-schema-objects.md) cũng như các kiến thức nền tảng về truy vấn SQL (xem ./04-querying-data-sql-fundamentals.md). Một số đánh đổi về schema design liên quan đến trigger (denormalization, computed column) được bàn sâu hơn ở ./17-schema-design-patterns-and-antipatterns.md.

Kiến thức nền tảng

PL/pgSQL cơ bản

Phần thân (body) của một function hoặc procedure viết bằng PL/pgSQL là một block được quote bằng $$ (hoặc bất kỳ dollar-quote tag tùy chỉnh nào, ví dụ $body$) với cấu trúc:

[ <<label>> ]
[ DECLARE
    -- khai báo biến
]
BEGIN
    -- các câu lệnh
[ EXCEPTION
    WHEN condition THEN
        -- xử lý ngoại lệ
]
END [ label ];

Biến (variable) được khai báo trong phần DECLARE với một type, và tùy chọn giá trị mặc định:

DECLARE
    v_customer_id  integer;
    v_full_name    text := 'unknown';
    v_price        numeric(10,2) := 0.00;
    v_row          orders%ROWTYPE;   -- kế thừa cấu trúc row của table "orders"
    v_count        int;

%TYPE%ROWTYPE là hai idiom quan trọng: v_customer_id customers.id%TYPE gắn type của biến với một column, nhờ đó nếu type của column đó thay đổi thì biến vẫn tự động đồng bộ theo.

Câu lệnh điều kiện dùng IF ... THEN ... ELSIF ... ELSE ... END IF, hoặc CASE:

IF v_price < 0 THEN
    RAISE EXCEPTION 'price cannot be negative: %', v_price;
ELSIF v_price = 0 THEN
    v_full_name := v_full_name || ' (free)';
ELSE
    v_full_name := v_full_name || ' (paid)';
END IF;

Vòng lặp có ba dạng phổ biến:

-- FOR trên một khoảng
FOR i IN 1..5 LOOP
    RAISE NOTICE 'iteration %', i;
END LOOP;

-- FOR trên kết quả một query (phổ biến nhất trong thực tế)
FOR v_row IN SELECT * FROM orders WHERE status = 'pending' LOOP
    RAISE NOTICE 'order % is pending', v_row.id;
END LOOP;

-- WHILE
WHILE v_count > 0 LOOP
    v_count := v_count - 1;
END LOOP;

-- LOOP trần với EXIT tường minh
LOOP
    v_count := v_count + 1;
    EXIT WHEN v_count >= 10;
END LOOP;

RAISE được dùng cả để in thông tin chẩn đoán lẫn để raise lỗi:

RAISE NOTICE 'processing customer %', v_customer_id;   -- thông tin, hiển thị cho client nếu log level cho phép
RAISE WARNING 'unexpected state for order %', v_row.id;
RAISE EXCEPTION 'invalid status: %', v_row.status
    USING ERRCODE = '22023', HINT = 'status must be one of pending/shipped/cancelled';

RAISE EXCEPTION sẽ hủy transaction block hiện tại (hoặc bị bắt bởi một EXCEPTION handler bao bọc bên ngoài); NOTICEWARNING không gây fatal.

Ví dụ function hoàn chỉnh: loop + aggregation + RETURN

CREATE OR REPLACE FUNCTION total_revenue_for_customer(p_customer_id integer)
RETURNS numeric
LANGUAGE plpgsql
AS $$
DECLARE
    v_order      record;
    v_total      numeric(12,2) := 0;
BEGIN
    FOR v_order IN
        SELECT o.id, o.total_amount, o.status
        FROM orders o
        WHERE o.customer_id = p_customer_id
    LOOP
        IF v_order.status = 'cancelled' THEN
            CONTINUE;               -- bỏ qua order đã cancelled
        END IF;

        v_total := v_total + v_order.total_amount;
    END LOOP;

    RETURN v_total;
END;
$$;

Cách dùng — vì đây là function nên có thể gọi ở bất cứ đâu một scalar expression được chấp nhận:

SELECT c.name, total_revenue_for_customer(c.id) AS revenue
FROM customers c
ORDER BY revenue DESC;

SELECT * FROM customers WHERE total_revenue_for_customer(id) > 1000;

Function

CREATE FUNCTION khai báo tên, danh sách argument, return type, ngôn ngữ, và body:

CREATE OR REPLACE FUNCTION full_name(p_first text, p_last text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
    RETURN trim(p_first) || ' ' || trim(p_last);
END;
$$;

Function cũng có thể trả về một tập hợp row (table-valued function), rất phổ biến khi cần một “view có tham số” tái sử dụng được:

CREATE OR REPLACE FUNCTION orders_above(p_min_amount numeric)
RETURNS TABLE(id integer, customer_id integer, total_amount numeric)
LANGUAGE sql
STABLE
AS $$
    SELECT o.id, o.customer_id, o.total_amount
    FROM orders o
    WHERE o.total_amount >= p_min_amount;
$$;

SELECT * FROM orders_above(500);

Chú ý biến thể LANGUAGE sql ở trên — với logic đơn giản, chỉ một câu query, một SQL function thường rõ ràng hơn và cho phép planner inline nó mạnh mẽ hơn so với một PL/pgSQL function.

Function có thể được gọi từ SELECT, WHERE, JOIN ... ON, generated column, CHECK constraint, index expression, và nhiều nơi khác — bất cứ đâu một value expression được phép — nhưng một lệnh gọi function bình thường không thể issue COMMIT hoặc ROLLBACK. Function luôn chạy bên trong transaction của caller.

Procedure

Procedure (PostgreSQL 11+) được khai báo bằng CREATE PROCEDURE và gọi bằng CALL, không phải SELECT. Đặc điểm định nghĩa của nó là có thể tự quản lý transaction bằng COMMITROLLBACK — điều mà function về bản chất không được phép làm, vì bộ thực thi query đang chạy lệnh gọi function đã nằm bên trong một transaction/snapshot mà nó không thể phá vỡ giữa chừng một expression.

CREATE OR REPLACE PROCEDURE archive_old_orders(p_batch_size integer DEFAULT 1000)
LANGUAGE plpgsql
AS $$
DECLARE
    v_rows_moved integer;
BEGIN
    LOOP
        WITH moved AS (
            DELETE FROM orders
            WHERE id IN (
                SELECT id FROM orders
                WHERE status = 'completed'
                  AND completed_at < now() - interval '1 year'
                LIMIT p_batch_size
            )
            RETURNING *
        )
        INSERT INTO orders_archive
        SELECT * FROM moved;

        GET DIAGNOSTICS v_rows_moved = ROW_COUNT;

        RAISE NOTICE 'archived % rows', v_rows_moved;

        COMMIT;    -- chỉ hợp lệ vì đây là PROCEDURE, và chỉ khi được gọi
                   -- ở top level (không lồng bên trong một transaction block khác)

        EXIT WHEN v_rows_moved < p_batch_size;
    END LOOP;
END;
$$;

CALL archive_old_orders(5000);

Đây chính xác là tình huống roadmap nhắc tới: một batch job chạy dài cần di chuyển hàng triệu row mà không giữ một transaction khổng lồ mở suốt (điều này sẽ làm WAL phình to, giữ lock, và cản trở vacuum thu hồi dead tuple). Commit định kỳ bên trong loop cho phép từng batch trở nên visible và durable một cách độc lập. Một function không thể làm được điều này — toàn bộ vòng FOR trong total_revenue_for_customer ở trên chạy dưới một snapshot duy nhất, không có khả năng commit giữa chừng.

COMMIT/ROLLBACK bên trong một procedure chỉ được phép khi procedure đó được gọi trực tiếp bằng CALL ở top level (không phải từ bên trong một function, không phải từ bên trong một transaction-controlled block khác, và không phải từ một trigger). Sau khi COMMIT bên trong một procedure, một transaction mới tự động bắt đầu cho phần còn lại của procedure body.

Bảng so sánh Function vs. Procedure

Khía cạnhFunctionProcedure
Khai báo bằngCREATE FUNCTIONCREATE PROCEDURE
Gọi bằngSELECT fn(...), hoặc nhúng trong expressionCALL proc(...)
Trả về giá trịCó (RETURNS type, scalar/set/table/void)Không (không có mệnh đề RETURNS; dùng tham số OUT/INOUT thay thế)
Dùng được trong SELECT/WHERE/JOINKhông
Tự COMMIT/ROLLBACK đượcKhông — chạy hoàn toàn bên trong transaction của callerCó, khi được gọi ở top level qua CALL
Trường hợp dùng điển hìnhTính toán, validation, table-valued helper, index expressionBatch/ETL job, tác vụ bảo trì, workflow nhiều bước cần checkpoint
Dùng làm trigger function được khôngCó (trigger function chính là một function trả về trigger, xem phần dưới)Không — trigger function bắt buộc phải là FUNCTION trả về trigger, không phải procedure
Xuất hiện từMọi phiên bảnPostgreSQL 11

Dòng cuối cùng đáng chú thích thêm: dù procedure đã có thêm khả năng điều khiển transaction, trình xử lý trigger vẫn bắt buộc phải là function (trả về pseudo-type đặc biệt trigger), vì nó thực thi như một phần của transaction thuộc câu lệnh kích hoạt và không được phép cố commit hay rollback transaction đó.

Khái niệm chính

Volatility của function: IMMUTABLE, STABLE, VOLATILE

Mỗi function đều có một hạng mục volatility cho query planner biết nó được phép tối ưu hóa lệnh gọi tới function đó mạnh mẽ đến mức nào. Điều này được khai báo bằng một keyword trong CREATE FUNCTION:

CREATE OR REPLACE FUNCTION age_in_years(p_birthdate date)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT EXTRACT(YEAR FROM age(current_date, p_birthdate))::integer;
$$;

Khoan — thoạt nhìn function này có vẻ immutable (chỉ là phép tính toán học thuần túy trên input), nhưng nó âm thầm phụ thuộc vào current_date, thứ thay đổi mỗi ngày. Đây chính xác là kiểu bug tinh vi mà roadmap cảnh báo: khai báo một function là IMMUTABLE trong khi thực ra nó phụ thuộc vào thứ gì đó nằm ngoài argument của nó. Khai báo đúng ở đây phải là STABLE (nó không ghi gì cả, nhưng kết quả của nó phụ thuộc vào current_date, thứ thay đổi giữa các lần gọi ở thời điểm khác nhau):

CREATE OR REPLACE FUNCTION age_in_years(p_birthdate date)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
    SELECT EXTRACT(YEAR FROM age(current_date, p_birthdate))::integer;
$$;

Tại sao điều này quan trọng trong thực tế:

-- Sử dụng an toàn một functional index — function phải ít nhất là STABLE
CREATE OR REPLACE FUNCTION normalize_email(p_email text)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT lower(trim(p_email));
$$;

CREATE INDEX idx_users_email_normalized ON users (normalize_email(email));

normalize_email thực sự là immutable: lower/trim trên một argument kiểu text không phụ thuộc vào gì ngoài chính argument đó (bỏ qua các trường hợp ngoại lệ về collation), nên index này an toàn và planner có thể dùng nó cho WHERE normalize_email(email) = 'foo@bar.com'.

Trigger

Một trigger gắn một trigger function vào một table (hoặc view, với INSTEAD OF) để nó tự động chạy quanh một sự kiện thay đổi dữ liệu. Ba chiều xác định hành vi của trigger:

Thời điểm (timing):

Độ chi tiết (granularity):

Các biến đặc biệt NEWOLD chỉ có sẵn bên trong một trigger function ở mức row:

Sự kiện triggerOLD có sẵnNEW có sẵn
INSERTkhông
UPDATE
DELETEkhông

Ví dụ thực tế: trigger tự động cập nhật updated_at

Một pattern rất phổ biến — giữ column updated_at luôn cập nhật mỗi khi có UPDATE, mà không phụ thuộc vào application code phải nhớ set nó:

CREATE TABLE products (
    id           serial PRIMARY KEY,
    name         text NOT NULL,
    price        numeric(10,2) NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);

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

CREATE TRIGGER trg_products_set_updated_at
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION set_updated_at();

Chú ý hình dạng của trigger function: nó không nhận argument tường minh theo nghĩa SQL, bắt buộc phải khai báo RETURNS trigger, và đọc/ghi row thông qua NEW/OLD (cả hai đều tự động có sẵn bên trong trigger function body, không được truyền như tham số). Vì đây là BEFORE, việc thay đổi NEW.updated_at và return NEW thực sự làm thay đổi những gì được ghi xuống đĩa.

UPDATE products SET price = 19.99 WHERE id = 1;
-- updated_at giờ đã cập nhật, dù câu lệnh UPDATE không hề nhắc tới nó

Ví dụ thực tế: trigger audit-log

Trường hợp dùng kinh điển thứ hai — ghi lại mọi thay đổi trên một table vào một history table dạng append-only, dùng AFTER (vì ta chỉ quan sát, không thay đổi việc ghi) và một trigger function tổng quát, dùng chung cho nhiều table nhờ TG_TABLE_NAME, TG_OP, và row_to_json:

CREATE TABLE audit_log (
    id            bigserial PRIMARY KEY,
    table_name    text NOT NULL,
    operation     text NOT NULL,
    old_data      jsonb,
    new_data      jsonb,
    changed_by    text NOT NULL DEFAULT current_user,
    changed_at    timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION audit_row_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log(table_name, operation, new_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW));
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO audit_log(table_name, operation, old_data, new_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit_log(table_name, operation, old_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD));
        RETURN OLD;
    END IF;
    RETURN NULL;
END;
$$;

CREATE TRIGGER trg_orders_audit
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION audit_row_change();

TG_OP, TG_TABLE_NAME, TG_WHEN, TG_LEVEL, và TG_ARGV[] là các biến ngầm định mà PostgreSQL tự điền vào bên trong mọi trigger function, cho phép một function tổng quát phục vụ nhiều table và nhiều thao tác. Function này có thể tái sử dụng đơn giản bằng cách tạo cùng câu lệnh CREATE TRIGGER trên bất kỳ table nào khác bạn muốn audit.

Các trường hợp dùng trigger phổ biến

Lưu ý: trigger là side effect vô hình

Mặt trái của việc “trigger tự động chạy” là chúng chạy vô hình từ góc nhìn của người viết câu lệnh gây kích hoạt. Một câu UPDATE products SET price = 19.99 WHERE id = 1; trông có vẻ đơn giản có thể âm thầm: cập nhật updated_at, ghi một row vào audit_log, tính lại một tổng vật lý hóa trên table cha, gửi một NOTIFY, và fail với một exception từ một rule hoàn toàn khác ở table cách đó ba bước. Ai đó chỉ đọc application code phát ra câu UPDATE sẽ không có cách nào biết bất kỳ điều gì trong số này đã xảy ra nếu không biết phải nhìn vào schema.

Điều này khiến trigger trở thành một công cụ hai lưỡi mà DBA phải cân nhắc:

Khuyến nghị best-practice: ghi tài liệu cho mục đích của mỗi trigger trực tiếp bằng comment (COMMENT ON TRIGGER ... IS '...') và giữ một quy ước đặt tên thể hiện rõ ý định (ví dụ trg_<table>_<purpose>). Tránh dùng trigger để thay thế cho business logic ở tầng application vốn không liên quan gì đến tính toàn vẹn dữ liệu — logic thực chất thuộc về workflow, thông báo tới hệ thống bên ngoài, hay validation hướng tới UI thường nên nằm ở application, nơi nó hiển thị được trong code review, test được, và trace được trong stack trace. Chỉ dành trigger cho logic phải luôn đúng bất kể client nào chạm vào dữ liệu — tính toàn vẹn dữ liệu, audit, và các invariant mà bản thân database phải chịu trách nhiệm đảm bảo.

System catalog: pg_catalog vs. information_schema

PostgreSQL lưu trữ metadata về mọi object nó quản lý — table, column, function, type, trigger, index, constraint, role, và nhiều hơn nữa — trong một tập hợp các system table nằm trong schema pg_catalog. Đây là cách database tự mô tả chính nó: \d trong psql, pg_dump, và query planner đều cuối cùng đọc từ pg_catalog.

Có hai cách để introspect metadata này:

Quy tắc thực dụng: ưu tiên information_schema trước cho việc introspect đơn giản, portable; chuyển sang pg_catalog khi cần thứ mà information_schema không phơi bày (thứ tự kích hoạt trigger, trigger có đang enable hay không, OID nội bộ, chủ sở hữu extension, v.v.).

Liệt kê trigger trên một table qua information_schema.triggers:

SELECT trigger_name,
       event_manipulation,   -- INSERT / UPDATE / DELETE
       action_timing,        -- BEFORE / AFTER
       action_orientation,   -- ROW / STATEMENT
       action_statement      -- mệnh đề EXECUTE FUNCTION
FROM information_schema.triggers
WHERE event_object_table = 'orders'
ORDER BY trigger_name;

Chú ý một điểm oái oăm: information_schema.triggers báo cáo một row cho mỗi event với trigger nhiều event (AFTER INSERT OR UPDATE OR DELETE), nên một CREATE TRIGGER duy nhất có thể xuất hiện thành ba row ở đây.

Cùng query đó nhưng qua pg_catalog trực tiếp, không bị điểm oái oăm nói trên và phơi bày nhiều chi tiết hơn (trạng thái enable/disable, OID function chính xác, có phải constraint trigger hay không):

SELECT t.tgname                                   AS trigger_name,
       c.relname                                  AS table_name,
       p.proname                                  AS function_name,
       t.tgenabled                                AS enabled_state,   -- 'O' = enabled, 'D' = disabled
       CASE WHEN t.tgtype & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END     AS granularity,
       CASE WHEN t.tgtype & 2 = 2 THEN 'BEFORE'
            WHEN t.tgtype & 64 = 64 THEN 'INSTEAD OF'
            ELSE 'AFTER' END                       AS timing
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_proc p  ON p.oid = t.tgfoid
WHERE c.relname = 'orders'
  AND NOT t.tgisinternal    -- loại bỏ các trigger nội bộ đứng sau FK constraint, v.v.
ORDER BY t.tgname;

Bộ lọc NOT t.tgisinternal rất quan trọng: PostgreSQL cài đặt các hành động ON DELETE/ON UPDATE của foreign key và một số kiểm tra constraint dưới dạng system trigger nội bộ trong pg_trigger, và nếu không có bộ lọc này, chúng sẽ làm nhiễu kết quả của bất kỳ query nào trên pg_trigger.

Bạn cũng có thể kiểm tra volatility đã khai báo của một function theo cách tương tự:

SELECT proname,
       CASE provolatile
           WHEN 'i' THEN 'IMMUTABLE'
           WHEN 's' THEN 'STABLE'
           WHEN 'v' THEN 'VOLATILE'
       END AS volatility
FROM pg_proc
WHERE proname = 'age_in_years';

Best Practices

Tài liệu tham khảo

Part of the PostgreSQL DBA Roadmap knowledge base.

Overview

PostgreSQL lets you push logic into the database itself, instead of (or in addition to) the application layer. Three building blocks make this possible: functions, which compute and return a value and can be embedded directly in SELECT, WHERE, or JOIN clauses; procedures (added in PostgreSQL 11), which run a sequence of steps and — unlike functions — can control their own transactions with COMMIT/ROLLBACK; and triggers, which attach a function to a table so it fires automatically on INSERT/UPDATE/DELETE/TRUNCATE.

All three are usually written in PL/pgSQL, PostgreSQL’s built-in procedural language — SQL with variables, conditionals, loops, and exception handling wrapped around it. Understanding PL/pgSQL is a prerequisite for everything else in this note, so we start there before moving into functions, procedures, volatility, and triggers.

This note assumes familiarity with basic data types and schema objects (see ./03-data-types-and-schema-objects.md) and with SQL querying fundamentals (see ./04-querying-data-sql-fundamentals.md). Some of the schema-design trade-offs around triggers (denormalization, computed columns) are covered in more depth in ./17-schema-design-patterns-and-antipatterns.md.

Fundamentals

PL/pgSQL basics

A PL/pgSQL function or procedure body is a $$-quoted block (or any custom dollar-quote tag, e.g. $body$) with the structure:

[ <<label>> ]
[ DECLARE
    -- variable declarations
]
BEGIN
    -- statements
[ EXCEPTION
    WHEN condition THEN
        -- handler
]
END [ label ];

Variables are declared in the DECLARE section with a type, and optionally a default:

DECLARE
    v_customer_id  integer;
    v_full_name    text := 'unknown';
    v_price        numeric(10,2) := 0.00;
    v_row          orders%ROWTYPE;   -- inherits the row structure of table "orders"
    v_count        int;

%TYPE and %ROWTYPE are important idioms: v_customer_id customers.id%TYPE ties the variable’s type to a column, so it stays in sync if the column type ever changes.

Conditionals use IF ... THEN ... ELSIF ... ELSE ... END IF, or CASE:

IF v_price < 0 THEN
    RAISE EXCEPTION 'price cannot be negative: %', v_price;
ELSIF v_price = 0 THEN
    v_full_name := v_full_name || ' (free)';
ELSE
    v_full_name := v_full_name || ' (paid)';
END IF;

Loops come in three common flavors:

-- FOR over a range
FOR i IN 1..5 LOOP
    RAISE NOTICE 'iteration %', i;
END LOOP;

-- FOR over a query result (most common in practice)
FOR v_row IN SELECT * FROM orders WHERE status = 'pending' LOOP
    RAISE NOTICE 'order % is pending', v_row.id;
END LOOP;

-- WHILE
WHILE v_count > 0 LOOP
    v_count := v_count - 1;
END LOOP;

-- Bare LOOP with an explicit EXIT
LOOP
    v_count := v_count + 1;
    EXIT WHEN v_count >= 10;
END LOOP;

RAISE is used both for diagnostic output and for raising errors:

RAISE NOTICE 'processing customer %', v_customer_id;   -- informational, visible to client if log level allows
RAISE WARNING 'unexpected state for order %', v_row.id;
RAISE EXCEPTION 'invalid status: %', v_row.status
    USING ERRCODE = '22023', HINT = 'status must be one of pending/shipped/cancelled';

RAISE EXCEPTION aborts the current transaction block (or is caught by an enclosing EXCEPTION handler); NOTICE and WARNING are non-fatal.

A complete function example: loop + aggregation + RETURN

CREATE OR REPLACE FUNCTION total_revenue_for_customer(p_customer_id integer)
RETURNS numeric
LANGUAGE plpgsql
AS $$
DECLARE
    v_order      record;
    v_total      numeric(12,2) := 0;
BEGIN
    FOR v_order IN
        SELECT o.id, o.total_amount, o.status
        FROM orders o
        WHERE o.customer_id = p_customer_id
    LOOP
        IF v_order.status = 'cancelled' THEN
            CONTINUE;               -- skip cancelled orders
        END IF;

        v_total := v_total + v_order.total_amount;
    END LOOP;

    RETURN v_total;
END;
$$;

Usage — because it’s a function, it can be called anywhere a scalar expression is valid:

SELECT c.name, total_revenue_for_customer(c.id) AS revenue
FROM customers c
ORDER BY revenue DESC;

SELECT * FROM customers WHERE total_revenue_for_customer(id) > 1000;

Functions

A CREATE FUNCTION declares a name, argument list, return type, language, and body:

CREATE OR REPLACE FUNCTION full_name(p_first text, p_last text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
    RETURN trim(p_first) || ' ' || trim(p_last);
END;
$$;

Functions can also return a set of rows (table-valued functions), which is common for reusable “parameterized views”:

CREATE OR REPLACE FUNCTION orders_above(p_min_amount numeric)
RETURNS TABLE(id integer, customer_id integer, total_amount numeric)
LANGUAGE sql
STABLE
AS $$
    SELECT o.id, o.customer_id, o.total_amount
    FROM orders o
    WHERE o.total_amount >= p_min_amount;
$$;

SELECT * FROM orders_above(500);

Note the LANGUAGE sql variant above — for simple, single-query logic, a plain SQL function is often clearer and lets the planner inline it more aggressively than a PL/pgSQL function.

Functions can be called from SELECT, WHERE, JOIN ... ON, generated columns, CHECK constraints, index expressions, and more — anywhere a value expression is allowed — but a plain function call cannot issue COMMIT or ROLLBACK. A function always runs inside the transaction of its caller.

Procedures

Procedures (PostgreSQL 11+) are declared with CREATE PROCEDURE and invoked with CALL, not SELECT. Their defining feature is that they can manage transactions internally using COMMIT and ROLLBACK — something a function is fundamentally not allowed to do, because the query executor that runs a function call is already inside a transaction/snapshot it cannot tear down mid-expression.

CREATE OR REPLACE PROCEDURE archive_old_orders(p_batch_size integer DEFAULT 1000)
LANGUAGE plpgsql
AS $$
DECLARE
    v_rows_moved integer;
BEGIN
    LOOP
        WITH moved AS (
            DELETE FROM orders
            WHERE id IN (
                SELECT id FROM orders
                WHERE status = 'completed'
                  AND completed_at < now() - interval '1 year'
                LIMIT p_batch_size
            )
            RETURNING *
        )
        INSERT INTO orders_archive
        SELECT * FROM moved;

        GET DIAGNOSTICS v_rows_moved = ROW_COUNT;

        RAISE NOTICE 'archived % rows', v_rows_moved;

        COMMIT;    -- only legal because this is a PROCEDURE, and only when called
                   -- at the top level (not nested inside another transaction block)

        EXIT WHEN v_rows_moved < p_batch_size;
    END LOOP;
END;
$$;

CALL archive_old_orders(5000);

This is exactly the scenario the roadmap calls out: a long-running batch job that needs to move millions of rows without holding one giant transaction open (which would bloat WAL, hold locks, and block vacuum from reclaiming dead tuples). Committing periodically inside the loop lets each batch become visible and durable independently. A function could not do this — the entire FOR loop in total_revenue_for_customer above runs under one snapshot with no possibility of an intermediate commit.

COMMIT/ROLLBACK inside a procedure is only allowed when the procedure is called directly by CALL at the top level (not from inside a function, not from inside another transaction-controlled block, and not from a trigger). After a COMMIT inside a procedure, a new transaction automatically begins for the remainder of the procedure body.

Functions vs. Procedures — comparison table

AspectFunctionProcedure
Declared withCREATE FUNCTIONCREATE PROCEDURE
Invoked withSELECT fn(...), or embedded in expressionsCALL proc(...)
Returns a valueYes (RETURNS type, scalar/set/table/void)No (no RETURNS clause; use OUT/INOUT params instead)
Usable in SELECT/WHERE/JOINYesNo
Can COMMIT/ROLLBACK internallyNo — runs entirely inside caller’s transactionYes, when called at top level via CALL
Typical use caseComputation, validation, table-valued helpers, index expressionsBatch/ETL jobs, maintenance tasks, multi-step workflows needing checkpoints
Can be used as a trigger functionNo (must return trigger, and cannot be a plain function used as a trigger — actually trigger functions are functions, see below)No — trigger functions must be FUNCTIONs returning trigger, not procedures
Introduced inAll versionsPostgreSQL 11

The last row deserves a footnote: even though procedures gained transaction control, trigger handlers are still required to be functions (returning the special pseudo-type trigger), because they execute as part of the firing statement’s transaction and must not attempt to commit or roll it back.

Key Concepts

Function volatility: IMMUTABLE, STABLE, VOLATILE

Every function has a volatility category that tells the query planner how aggressively it may optimize calls to that function. This is declared with a keyword in CREATE FUNCTION:

CREATE OR REPLACE FUNCTION age_in_years(p_birthdate date)
RETURNS integer
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT EXTRACT(YEAR FROM age(current_date, p_birthdate))::integer;
$$;

Wait — this looks immutable at first glance (pure math on the input), but it silently depends on current_date, which changes daily. This is exactly the kind of subtle bug the roadmap warns about: declaring a function IMMUTABLE when it actually depends on something outside its arguments. The correct declaration here is STABLE (it doesn’t write anything, but its result depends on current_date, which changes across calls at different times):

CREATE OR REPLACE FUNCTION age_in_years(p_birthdate date)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
    SELECT EXTRACT(YEAR FROM age(current_date, p_birthdate))::integer;
$$;

Why this matters in practice:

-- Safe use of a functional index — the function must be at least STABLE
CREATE OR REPLACE FUNCTION normalize_email(p_email text)
RETURNS text
LANGUAGE sql
IMMUTABLE
AS $$
    SELECT lower(trim(p_email));
$$;

CREATE INDEX idx_users_email_normalized ON users (normalize_email(email));

normalize_email genuinely is immutable: lower/trim on a text argument depends on nothing but the argument (collation caveats aside), so this index is safe and the planner may use it for WHERE normalize_email(email) = 'foo@bar.com'.

Triggers

A trigger binds a trigger function to a table (or view, for INSTEAD OF) so that it runs automatically around a data-modifying event. Three dimensions define trigger behavior:

Timing:

Granularity:

The special variables NEW and OLD are only available inside a row-level trigger function:

Trigger eventOLD availableNEW available
INSERTnoyes
UPDATEyesyes
DELETEyesno

Worked example: updated_at auto-update trigger

A very common pattern — keep a updated_at timestamp column current on every UPDATE, without relying on application code to remember to set it:

CREATE TABLE products (
    id           serial PRIMARY KEY,
    name         text NOT NULL,
    price        numeric(10,2) NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now(),
    updated_at   timestamptz NOT NULL DEFAULT now()
);

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

CREATE TRIGGER trg_products_set_updated_at
    BEFORE UPDATE ON products
    FOR EACH ROW
    EXECUTE FUNCTION set_updated_at();

Note the shape of the trigger function: it takes no explicit arguments in the SQL sense, must be declared RETURNS trigger, and reads/writes the row via NEW/OLD (both of which are implicitly available inside a trigger function body, not passed as parameters). Because this is BEFORE, mutating NEW.updated_at and returning NEW actually changes what gets written to disk.

UPDATE products SET price = 19.99 WHERE id = 1;
-- updated_at is now current, even though the UPDATE statement never mentioned it

Worked example: audit-log trigger

A second canonical use case — recording every change to a table in an append-only history table, using AFTER (since we’re only observing, not altering, the write) and a generic trigger function that works across multiple tables via TG_TABLE_NAME, TG_OP, and row_to_json:

CREATE TABLE audit_log (
    id            bigserial PRIMARY KEY,
    table_name    text NOT NULL,
    operation     text NOT NULL,
    old_data      jsonb,
    new_data      jsonb,
    changed_by    text NOT NULL DEFAULT current_user,
    changed_at    timestamptz NOT NULL DEFAULT now()
);

CREATE OR REPLACE FUNCTION audit_row_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log(table_name, operation, new_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(NEW));
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO audit_log(table_name, operation, old_data, new_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD), to_jsonb(NEW));
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit_log(table_name, operation, old_data)
        VALUES (TG_TABLE_NAME, TG_OP, to_jsonb(OLD));
        RETURN OLD;
    END IF;
    RETURN NULL;
END;
$$;

CREATE TRIGGER trg_orders_audit
    AFTER INSERT OR UPDATE OR DELETE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION audit_row_change();

TG_OP, TG_TABLE_NAME, TG_WHEN, TG_LEVEL, and TG_ARGV[] are implicit variables PostgreSQL populates inside every trigger function, letting one generic function serve many tables and operations. This function can be reused simply by creating the same CREATE TRIGGER statement on any other table you want audited.

Common trigger use cases

The caution: triggers are invisible side effects

The flip side of “triggers run automatically” is that they run invisibly from the point of view of whoever writes the triggering statement. A plain-looking UPDATE products SET price = 19.99 WHERE id = 1; might silently: update updated_at, write a row to audit_log, recompute a materialized total on a parent table, send a NOTIFY, and fail with an exception from a completely different-looking rule three tables away. Someone reading only the application code that issued the UPDATE has no way to know any of this happened without knowing to look at the schema.

This makes triggers a double-edged tool for a DBA to reason about:

Best-practice guidance: document every trigger’s purpose directly in a comment (COMMENT ON TRIGGER ... IS '...') and keep a naming convention that signals intent (e.g. trg_<table>_<purpose>). Avoid using triggers as a substitute for application-layer business logic that has nothing to do with data integrity — logic that’s really about workflow, notifications to external systems, or UI-facing validation usually belongs in the application, where it’s visible in code review, testable, and traceable in stack traces. Reserve triggers for logic that must hold regardless of which client touches the data — data integrity, auditing, and invariants that the database itself is responsible for guaranteeing.

The system catalog: pg_catalog vs. information_schema

PostgreSQL stores metadata about every object it manages — tables, columns, functions, types, triggers, indexes, constraints, roles, and more — in a set of system tables that live in the pg_catalog schema. This is how the database describes itself to itself: \d in psql, pg_dump, and the query planner all ultimately read pg_catalog.

There are two ways to introspect this metadata:

A practical rule of thumb: reach for information_schema first for simple, portable introspection; drop down to pg_catalog when you need something information_schema doesn’t expose (trigger firing order, whether a trigger is currently enabled, internal OIDs, extension ownership, etc.).

Listing triggers on a table via information_schema.triggers:

SELECT trigger_name,
       event_manipulation,   -- INSERT / UPDATE / DELETE
       action_timing,        -- BEFORE / AFTER
       action_orientation,   -- ROW / STATEMENT
       action_statement      -- the EXECUTE FUNCTION clause
FROM information_schema.triggers
WHERE event_object_table = 'orders'
ORDER BY trigger_name;

Note one quirk: information_schema.triggers reports one row per event for a multi-event trigger (AFTER INSERT OR UPDATE OR DELETE), so a single CREATE TRIGGER can show up as three rows here.

The same query via pg_catalog directly, which is not subject to that quirk and exposes more detail (enabled/disabled state, the exact function OID, whether it’s a constraint trigger):

SELECT t.tgname                                   AS trigger_name,
       c.relname                                  AS table_name,
       p.proname                                  AS function_name,
       t.tgenabled                                AS enabled_state,   -- 'O' = enabled, 'D' = disabled
       CASE WHEN t.tgtype & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END     AS granularity,
       CASE WHEN t.tgtype & 2 = 2 THEN 'BEFORE'
            WHEN t.tgtype & 64 = 64 THEN 'INSTEAD OF'
            ELSE 'AFTER' END                       AS timing
FROM pg_trigger t
JOIN pg_class c ON c.oid = t.tgrelid
JOIN pg_proc p  ON p.oid = t.tgfoid
WHERE c.relname = 'orders'
  AND NOT t.tgisinternal    -- exclude internal triggers backing FK constraints, etc.
ORDER BY t.tgname;

The NOT t.tgisinternal filter matters: PostgreSQL implements foreign-key ON DELETE/ON UPDATE actions and some constraint checks internally as system triggers on pg_trigger, and without this filter they clutter the output of any query against pg_trigger.

You can inspect a function’s declared volatility the same way:

SELECT proname,
       CASE provolatile
           WHEN 'i' THEN 'IMMUTABLE'
           WHEN 's' THEN 'STABLE'
           WHEN 'v' THEN 'VOLATILE'
       END AS volatility
FROM pg_proc
WHERE proname = 'age_in_years';

Best Practices

References