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 và %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); NOTICE và WARNING 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 COMMIT và ROLLBACK — đ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ạnh | Function | Procedure |
|---|---|---|
| Khai báo bằng | CREATE FUNCTION | CREATE PROCEDURE |
| Gọi bằng | SELECT fn(...), hoặc nhúng trong expression | CALL 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/JOIN | Có | Không |
Tự COMMIT/ROLLBACK được | Không — chạy hoàn toàn bên trong transaction của caller | Có, khi được gọi ở top level qua CALL |
| Trường hợp dùng điển hình | Tính toán, validation, table-valued helper, index expression | Batch/ETL job, tác vụ bảo trì, workflow nhiều bước cần checkpoint |
| Dùng làm trigger function được không | Có (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ản | PostgreSQL 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:
IMMUTABLE— với cùng argument, function luôn trả về cùng kết quả, mãi mãi, không phụ thuộc vào trạng thái database. Ví dụ:upper(text), một function tính toán số học thuần túy, một function chỉ nhìn vào argument của chính nó và các hằng số.STABLE— function có thể đọc từ database (table, setting hiện tại) nhưng được đảm bảo không thay đổi database, và trả về cùng kết quả với cùng argument trong phạm vi một câu lệnh/scan. Ví dụ: một function tra tỷ giá từ tablerates, hoặc gọicurrent_date/current_setting.VOLATILE(mặc định nếu không khai báo) — kết quả của function có thể thay đổi giữa các lần gọi dù cùng argument, và/hoặc có side effect (ghi dữ liệu, tăng sequence,RAISE, gọirandom(),nextval(), sửa table). PostgreSQL phải gọi nó một lần cho mỗi row/lệnh gọi và không được cache hay đổi thứ tự các lệnh gọi này.
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ế:
- Nếu bạn đánh dấu một function là
IMMUTABLEtrong khi nó không phải vậy, PostgreSQL có thể xây dựng một index trên expression dùng function đó, hoặc gộp các lệnh gọi lặp lại thành một hằng số trong quá trình planning, và giá trị được cache/index đó âm thầm trở nên lỗi thời (stale). Một sự cố thực tế kinh điển: một functionIMMUTABLEformat mộttimestampdùng settingtimezonecủa session — đổi timezone, và một functional index xây dựng dựa trên nó trở nên sai mà không hề có lỗi nào được báo. - Ngược lại, đánh dấu một thứ là
VOLATILEtrong khi nó thực raSTABLEhoặcIMMUTABLEkhông gây bug về tính đúng đắn, chỉ là bỏ lỡ cơ hội tối ưu — planner sẽ đánh giá lại function thường xuyên hơn nhiều so với cần thiết (ví dụ, một lần cho mỗi row được scan thay vì một lần cho mỗi query), điều này có thể là chi phí hiệu năng thực sự trong một mệnh đềWHEREhoặc mộtSELECTlớn. - Function
STABLE/IMMUTABLElà ứng viên để dùng trong một functional index và có thể được inline/constant-fold bởi planner; functionVOLATILEthì không bao giờ, vì làm vậy có thể thay đổi hành vi quan sát được (ví dụ, một functionVOLATILEgọinextval()phải chạy đúng một lần cho mỗi row).
-- 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):
BEFORE— kích hoạt trước khi thao tác được áp dụng; trigger function có thể kiểm tra và sửa row sắp được ghi (bằng cách thay đổi và returnNEW), hoặc hủy hoàn toàn thao tác bằng cách returnNULL.AFTER— kích hoạt sau khi thao tác đã được áp dụng; thường dùng để logging/auditing/gây side effect kế tiếp, vì row đã được commit vào table (trong phạm vi transaction) tại thời điểm trigger chạy. TriggerAFTERkhông thể thay đổi row đang được ghi (return một giá trịNEWkhác không có tác dụng gì, vì việc ghi đã xảy ra rồi).INSTEAD OF— chỉ hợp lệ trên view; thay thế hoàn toàn thao tác, cho phép một view vốn không update được có thể hỗ trợINSERT/UPDATE/DELETEbằng cách chuyển thao tác đó thành logic tác động lên base table.
Độ chi tiết (granularity):
FOR EACH ROW— trigger function chạy một lần cho mỗi row bị ảnh hưởng, vớiNEW/OLDgắn với row đó.FOR EACH STATEMENT— trigger function chỉ chạy một lần cho toàn bộ câu lệnh, bất kể có bao nhiêu row bị ảnh hưởng;NEW/OLDkhông có sẵn dưới dạng biến single-row (mặc dù từ PostgreSQL 10+ có thể truy cập toàn bộ tập row bị ảnh hưởng qua transition table,REFERENCING NEW TABLE AS ... OLD TABLE AS ...).
Các biến đặc biệt NEW và OLD chỉ có sẵn bên trong một trigger function ở mức row:
| Sự kiện trigger | OLD có sẵn | NEW có sẵn |
|---|---|---|
INSERT | không | có |
UPDATE | có | có |
DELETE | có | khô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
- Duy trì các column denormalized hoặc computed — ví dụ giữ column
order_totaltrên row chaordersđồng bộ với tổng của cácorder_items, hoặc duy trì columntsvectorcho full-text search quatsvector_update_trigger. (Xem ./17-schema-design-patterns-and-antipatterns.md để biết khi nào denormalization là ý hay và khi nào không.) - Audit các thay đổi — pattern
audit_logở trên, hoặc các extension/pattern chuyên biệt hơn của PostgreSQL cho temporal table. - Thực thi các business rule phức tạp mà một
CHECKconstraint không thể diễn đạt được vì chúng cần tra cứu xuyên row hoặc xuyên table (CHECKconstraint chỉ nhìn thấy row đang được validate, không bao giờ thấy các row khác) — ví dụ “manager của một employee phải cùng phòng ban,” được kiểm tra qua một triggerBEFORE INSERT OR UPDATEchạy mộtSELECTtrên table khác vàRAISE EXCEPTIONnếu vi phạm rule. - Kích hoạt logic tùy chỉnh theo dây chuyền (cascading) — ví dụ tự động tạo một row
walletsmỗi khi có một rowusersmới được insert, hoặc archive các row con liên quan trước khi xóa row cha khi chỉ riêngON DELETE CASCADEkhông đủ diễn đạt.
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:
- Debug trở nên khó hơn. Một câu
UPDATEchậm hoặc fail có thể thực ra chậm hoặc fail vì một trigger đang làm công việc không liên quan, chứ không phải vì bản thân câu lệnh.EXPLAIN ANALYZEtrên một câu lệnh có trigger sẽ hiển thị thời gian thực thi trigger như một dòng riêng, thường là manh mối đầu tiên. - Thứ tự thực thi quan trọng và dễ sai khi có nhiều trigger trên cùng table/event — PostgreSQL kích hoạt chúng theo thứ tự tên (alphabet) cho cùng timing/event, đây là nguồn gốc phổ biến của sự bối rối “tại sao trigger B chạy trước trigger A”. Đặt prefix cho tên trigger để chủ động kiểm soát thứ tự (ví dụ
trg_01_...,trg_02_...) nếu thứ tự có ý nghĩa. - Trigger đệ quy/dây chuyền qua nhiều table có thể tạo ra các chuỗi side effect khó lần theo, và trong trường hợp xấu nhất, tạo vòng lặp vô hạn chỉ được chặn bởi kiểm tra
pg_trigger_depth().
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:
information_schema— một tập hợp view được định nghĩa bởi chuẩn SQL, có mặt (với một số khác biệt) trên nhiều hệ quản trị database. Query trên nó portable hơn qua các phiên bản PostgreSQL và thậm chí qua các sản phẩm database khác nhau, nhưng tất yếu không thể phơi bày bất kỳ thứ gì đặc thù của PostgreSQL (như tablespace, hay thứ tự kích hoạt trigger chính xác của PostgreSQL) vì chuẩn không định nghĩa các khái niệm đó.pg_catalog— các catalog table gốc của PostgreSQL (pg_class,pg_attribute,pg_proc,pg_trigger,pg_constraint, v.v.), luôn đầy đủ, luôn chính xác, và phơi bày mọi chi tiết đặc thù của PostgreSQL, đổi lại là gắn chặt với PostgreSQL và kém ổn định hơn qua các major version so với các view theo chuẩn SQL.
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
- Ưu tiên function hơn procedure trừ khi thực sự cần điều khiển transaction. Chỉ dùng procedure khi workload thực sự cần
COMMITgiữa chừng — archival theo batch, backfill hàng loạt, ETL job chạy dài — không phải như một thói quen mặc định. - Mặc định dùng
VOLATILEkhi còn phân vân. Nó luôn đúng, chỉ có thể chậm hơn. Chỉ nâng một function lênSTABLEhayIMMUTABLEsau khi đã xác minh nó thực sự không phụ thuộc vào bất cứ gì ngoài argument của nó (vớiIMMUTABLE) hoặc không bao giờ thay đổi database (vớiSTABLE); một khai báoIMMUTABLEsai là một bug về tính đúng đắn, không chỉ là bỏ lỡ tối ưu. - Dùng function
SECURITY DEFINERmột cách tiết kiệm và thận trọng. Một function đánh dấuSECURITY DEFINERchạy với quyền của owner, không phải của caller — hữu ích cho việc nâng quyền có kiểm soát (ví dụ cho một role ít quyền update một row qua một function hẹp), nhưng luôn ghimsearch_pathtường minh bên trong các function như vậy để tránh tấn công search-path-hijacking. - Đặt tên và comment rõ ràng cho mọi trigger, và áp dụng một quy ước đặt tên (
trg_<table>_<action>) cũng thể hiện thứ tự kích hoạt khi có nhiều trigger trên cùng một event. - Giữ trigger function nhỏ gọn và nhanh, đặc biệt là trigger
BEFORE ROWtrên đường ghi dữ liệu nóng — chúng chạy đồng bộ bên trong mọiINSERT/UPDATE/DELETEbị ảnh hưởng, và logic trigger chậm sẽ trở thành độ trễ vô hình trên câu lệnh gọi. - Tránh các chuỗi trigger đệ quy/dây chuyền qua nhiều table; khi có thể, ưu tiên trigger
AFTER STATEMENTvới transition table thay vì logic theo từng row khi xử lý các thao tác hàng loạt, vì một triggerFOR EACH ROWkích hoạt trên mộtUPDATEhàng loạt 500.000 row sẽ chạy 500.000 lần. - Dùng
information_schemacho tooling portable, hướng ra bên ngoài (ORM, migration tool, sinh tài liệu) vàpg_catalogkhi cần chi tiết đặc thù PostgreSQL hoặc hiệu năng tốt hơn cho tooling nặng về introspection. - Test trigger một cách tường minh, không chỉ test các table mà chúng gắn vào — một bộ test pass cho “insert một row” có thể che giấu một audit trigger bị hỏng đã âm thầm nuốt mất một exception.
Tài liệu tham khảo
- PostgreSQL Docs — PL/pgSQL
- PostgreSQL Docs — CREATE FUNCTION
- PostgreSQL Docs — CREATE PROCEDURE
- PostgreSQL Docs — Trigger Behavior
- PostgreSQL Docs — CREATE TRIGGER
- PostgreSQL Docs — PL/pgSQL Trigger Procedures
- PostgreSQL Docs — information_schema
- PostgreSQL Docs — System Catalogs (pg_trigger, pg_proc)
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
| Aspect | Function | Procedure |
|---|---|---|
| Declared with | CREATE FUNCTION | CREATE PROCEDURE |
| Invoked with | SELECT fn(...), or embedded in expressions | CALL proc(...) |
| Returns a value | Yes (RETURNS type, scalar/set/table/void) | No (no RETURNS clause; use OUT/INOUT params instead) |
Usable in SELECT/WHERE/JOIN | Yes | No |
Can COMMIT/ROLLBACK internally | No — runs entirely inside caller’s transaction | Yes, when called at top level via CALL |
| Typical use case | Computation, validation, table-valued helpers, index expressions | Batch/ETL jobs, maintenance tasks, multi-step workflows needing checkpoints |
| Can be used as a trigger function | No (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 in | All versions | PostgreSQL 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:
IMMUTABLE— the function, given the same arguments, always returns the same result, forever, with no dependency on database state. Example:upper(text), a pure arithmetic function, a function that only looks at its own arguments and constants.STABLE— the function may read from the database (tables, current settings) but is guaranteed not to change the database, and returns the same result for the same arguments within a single statement/scan. Example: a function that looks up an exchange rate from aratestable, or callscurrent_date/current_setting.VOLATILE(the default if unspecified) — the function’s result can change from call to call even with the same arguments, and/or it may have side effects (writes, sequence advances,RAISE, callingrandom(),nextval(), modifying tables). PostgreSQL must call it once per row/invocation and cannot cache or reorder its calls.
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:
- If you mark a function
IMMUTABLEand it is not, PostgreSQL may build an index on an expression using that function, or fold repeated calls into one constant during planning, and the cached/indexed value silently goes stale. A classic real incident: anIMMUTABLEfunction that formats atimestampusing the session’stimezonesetting — change the timezone, and a functional index built on it becomes wrong without any error ever being raised. - Conversely, marking something
VOLATILEwhen it’s actuallySTABLEorIMMUTABLEdoesn’t cause correctness bugs, only missed optimizations — the planner will re-evaluate the function far more often than necessary (e.g., once per row scanned instead of once per query), which can be a real performance cost in aWHEREclause or a largeSELECT. STABLE/IMMUTABLEfunctions are candidates for use in a functional index and can be inlined/constant-folded by the planner;VOLATILEfunctions never can be, because doing so could change observable behavior (e.g. aVOLATILEfunction that callsnextval()must run exactly once per row).
-- 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:
BEFORE— fires before the operation is applied; the trigger function can inspect and modify the row that is about to be written (by changing and returningNEW), or cancel the operation entirely by returningNULL.AFTER— fires after the operation has been applied; commonly used for logging/auditing/cascading side effects, since the row is already committed to the table (within the transaction) by the time the trigger runs.AFTERtriggers cannot change the row being written (returning a different value fromNEWhas no effect, since the write already happened).INSTEAD OF— only valid on views; replaces the operation entirely, letting you make an otherwise non-updatable view supportINSERT/UPDATE/DELETEby translating the operation into logic against the base tables.
Granularity:
FOR EACH ROW— the trigger function runs once per affected row, withNEW/OLDbound to that row.FOR EACH STATEMENT— the trigger function runs once total per statement, regardless of how many rows were affected;NEW/OLDare not available as single-row variables (though PostgreSQL 10+ allows access to the full set of affected rows via transition tables,REFERENCING NEW TABLE AS ... OLD TABLE AS ...).
The special variables NEW and OLD are only available inside a row-level trigger function:
| Trigger event | OLD available | NEW available |
|---|---|---|
INSERT | no | yes |
UPDATE | yes | yes |
DELETE | yes | no |
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
- Maintaining denormalized or computed columns — e.g. keeping an
order_totalcolumn on a parentordersrow in sync with the sum of itsorder_items, or maintaining afull_text_searchtsvectorcolumn viatsvector_update_trigger. (See ./17-schema-design-patterns-and-antipatterns.md for when denormalization is and isn’t a good idea.) - Auditing changes — the
audit_logpattern above, or PostgreSQL’s more specialized extensions/patterns for temporal tables. - Enforcing complex business rules that a
CHECKconstraint cannot express because they require cross-row or cross-table lookups (aCHECKconstraint can only see the row being validated, never other rows) — e.g. “an employee’s manager must belong to the same department,” verified via aBEFORE INSERT OR UPDATEtrigger that runs aSELECTagainst another table andRAISE EXCEPTIONs if the rule is violated. - Cascading custom logic — e.g. automatically creating a
walletsrow whenever a newusersrow is inserted, or archiving related child rows before a parent delete whenON DELETE CASCADEalone isn’t expressive enough.
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:
- Debugging gets harder. A slow or failing
UPDATEmight actually be slow or failing because of a trigger doing unrelated work, not because of the statement itself.EXPLAIN ANALYZEon a statement with triggers will show trigger execution time as a separate line, which is often the first clue. - Ordering matters and is easy to get wrong when multiple triggers exist on the same table/event — PostgreSQL fires them in name order (alphabetical) for the same timing/event, which is a common source of “why did trigger B run before trigger A” confusion. Prefix trigger names to control ordering deliberately (e.g.
trg_01_...,trg_02_...) if order matters. - Recursive/cascading triggers across multiple tables can create hard-to-trace chains of side effects, and in the worst case, infinite loops guarded only by
pg_trigger_depth()checks.
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:
information_schema— a set of views defined by the SQL standard, present (with variations) across many database systems. Queries against it are more portable across PostgreSQL versions and even across different database products, but it necessarily can’t expose anything PostgreSQL-specific (like tablespaces, or PostgreSQL’s exact trigger firing order) since the standard doesn’t define those concepts.pg_catalog— PostgreSQL’s native catalog tables (pg_class,pg_attribute,pg_proc,pg_trigger,pg_constraint, etc.), which are always complete, always accurate, and expose every PostgreSQL-specific detail, at the cost of being PostgreSQL-specific and less stable across major versions than the SQL-standard views.
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
- Prefer functions over procedures unless you specifically need transaction control. Reach for a procedure only when the workload genuinely needs intermediate
COMMITs — batch archival, bulk backfills, long ETL jobs — not as a default habit. - Default to
VOLATILEwhen in doubt. It’s always correct, just potentially slower. Only promote a function toSTABLEorIMMUTABLEafter verifying it truly has no dependency on anything outside its arguments (forIMMUTABLE) or never modifies the database (forSTABLE); an incorrectIMMUTABLEdeclaration is a correctness bug, not just a missed optimization. - Use
SECURITY DEFINERfunctions sparingly and defensively. A function markedSECURITY DEFINERruns with the privileges of its owner, not the caller — useful for controlled privilege escalation (e.g. letting a low-privilege role update a row via a narrow function), but always pinsearch_pathexplicitly inside such functions to avoid search-path-hijacking attacks. - Name and comment every trigger clearly, and adopt a naming convention (
trg_<table>_<action>) that also communicates fire order when multiple triggers exist on one event. - Keep trigger functions small and fast, especially
BEFORE ROWtriggers on hot write paths — they run synchronously inside every affectedINSERT/UPDATE/DELETE, and slow trigger logic becomes invisible latency on the calling statement. - Avoid recursive/cascading trigger chains across many tables; where possible, prefer
AFTER STATEMENTtriggers with transition tables over per-row logic when processing bulk operations, since aFOR EACH ROWtrigger firing on a 500,000-row bulkUPDATEruns 500,000 times. - Use
information_schemafor portable, external-facing tooling (ORMs, migration tools, docs generation) andpg_catalogwhen you need PostgreSQL-specific detail or better performance on introspection-heavy tooling. - Test triggers explicitly, not just the tables they’re attached to — a passing test suite for “insert a row” can hide a broken audit trigger that silently swallowed an exception.
References
- PostgreSQL Docs — PL/pgSQL
- PostgreSQL Docs — CREATE FUNCTION
- PostgreSQL Docs — CREATE PROCEDURE
- PostgreSQL Docs — Trigger Behavior
- PostgreSQL Docs — CREATE TRIGGER
- PostgreSQL Docs — PL/pgSQL Trigger Procedures
- PostgreSQL Docs — information_schema
- PostgreSQL Docs — System Catalogs (pg_trigger, pg_proc)