Comprehensive PostgreSQL reference for developers and DBAs covering versions 14–18. Use whenever the user asks about PostgreSQL syntax, DDL/DML/DQL, joins, LATERAL, CTEs, window functions, GROUPING SETS, DISTINCT ON, RETURNING, ON CONFLICT, PL/pgSQL, functions, procedures, triggers, views, materialized views, indexes (B-tree/GIN/GiST/BRIN/Hash/Bloom), MVCC, VACUUM, autovacuum, WAL, TOAST, partitioning, replication (streaming/logical), backup, PITR, HA (Patroni/repmgr), pgBouncer, EXPLAIN ANALYZE, RLS, roles, extensions (pgvector, PostGIS, TimescaleDB, Citus, pg_trgm, pg_cron), JSON/JSONB, full-text search, UUID, timestamptz, COPY, system catalogs, collations, large objects, cursors, GUC, or any Postgres administration, performance, security, replication, backup, or recovery topic.
94
94%
Does it follow best practices?
Impact
94%
1.36xAverage score across 3 eval scenarios
Passed
No known issues
PostgreSQL procedures, CALL, and the embedded COMMIT / ROLLBACK discipline that distinguishes them from functions.
Use this file when:
OUT / INOUT parameters (introduced for procedures in PostgreSQL 141).invalid transaction termination while trying to COMMIT inside a function, a SECURITY DEFINER procedure, a procedure with a SET clause, an EXCEPTION block, or a cursor loop over a non-read-only command.CALL ... INTO ... form.pg_cron and need to know whether to register a function or a procedure.For function mechanics (mutability, parallel safety, polymorphic types, SQL-function inlining, LEAKPROOF, COST/ROWS, STRICT), see 06-functions.md. For PL/pgSQL block grammar — DECLARE, control flow, exception handling, dynamic SQL — see 08-plpgsql.md. For transactions and isolation generally, see 41-transactions.md and 42-isolation-levels.md.
Procedures can COMMIT and ROLLBACK. Functions cannot. Everything else falls out of that single contract:
| Capability | Function | Procedure |
|---|---|---|
| Invocation form | inside SELECT / DML | CALL name(...) standalone |
| Returns | scalar / SETOF / TABLE | OUT/INOUT parameters only (PG14+)1 |
RETURNS clause | yes | no (omitted by grammar)2 |
Can COMMIT / ROLLBACK | no | yes — top-level only3 |
Volatility (VOLATILE/STABLE/IMMUTABLE) | yes | not applicable2 |
| Parallel safety | yes | not applicable |
STRICT / CALLED ON NULL INPUT | yes | not applicable2 |
LEAKPROOF | yes (planner-relevant) | not applicable |
COST / ROWS | yes | not applicable |
SECURITY DEFINER / INVOKER | yes | yes (but disables transaction control)4 |
SET configuration_parameter | yes | yes (but disables transaction control)4 |
| Usable in expressions | yes | no |
| Usable in index expressions | yes (IMMUTABLE only) | no |
[!NOTE] PostgreSQL 11 Procedures and
CALLwere introduced in PostgreSQL 11 along with transaction control inside server-side languages (PL/pgSQL, PL/Perl, PL/Python, PL/Tcl, SPI).5
[!NOTE] PostgreSQL 14 Procedures gained
OUTparameters in PostgreSQL 14, allowing them to return data to their caller (previously onlyINOUTwas usable for output).1
The canonical grammar from the manual4:
CREATE [ OR REPLACE ] PROCEDURE
name ( [ [ argmode ] [ argname ] argtype [ { DEFAULT | = } default_expr ] [, ...] ] )
{ LANGUAGE lang_name
| TRANSFORM { FOR TYPE type_name } [, ... ]
| [ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER
| SET configuration_parameter { TO value | = value | FROM CURRENT }
| AS 'definition'
| AS 'obj_file', 'link_symbol'
| sql_body
} ...A minimal example:
CREATE PROCEDURE refresh_dashboards()
LANGUAGE plpgsql
AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_daily;
COMMIT;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_traffic_daily;
COMMIT;
END;
$$;
CALL refresh_dashboards();CREATE OR REPLACE PROCEDURE replaces the body of an existing procedure. Per the docs4:
"To replace the current definition of an existing procedure, use
CREATE OR REPLACE PROCEDURE. It is not possible to change the name or argument types of a procedure this way (if you tried, you would actually be creating a new, distinct procedure)."
"When
CREATE OR REPLACE PROCEDUREis used to replace an existing procedure, the ownership and permissions of the procedure do not change."
To actually rename or reshape arguments, use ALTER PROCEDURE ... RENAME TO ... or DROP PROCEDURE ... + new CREATE PROCEDURE.
| Language | Notes |
|---|---|
plpgsql | The natural default. The only built-in language that combines control flow + transaction control elegantly. |
sql | Procedures in SQL exist (with or without BEGIN ATOMIC — see below) but cannot use COMMIT / ROLLBACK because there is no procedural control flow. Use them for simple CALL-wrapped DML batches. |
plperl / plperlu / plpython3u / pltcl / pltclu | All gained transaction control in PG11. Untrusted variants only. See 09-procedural-languages.md. |
c | Same restrictions; transaction control via SPI. Advanced; see 72-extension-development.md. |
The default LANGUAGE is sql if a sql_body (BEGIN ATOMIC ... END) is given; otherwise specify it explicitly.4
| Mode | Direction | Notes |
|---|---|---|
IN (default) | caller → procedure | the usual case |
OUT | procedure → caller | PG14+ for procedures1; column in the result row of CALL |
INOUT | both | works pre-PG14; the only way to "return" anything from a procedure before PG14 |
VARIADIC | caller → procedure | last parameter only; collects extra args into an array |
CALL requires you to supply arguments for OUT parameters too, but those expressions are not evaluated. Conventional usage is NULL6:
"Arguments must be supplied for all procedure parameters that lack defaults, including OUT parameters. However, arguments matching OUT parameters are not evaluated, so it's customary to write
NULLfor them. (Writing something else for an OUT parameter might cause compatibility problems with future PostgreSQL versions.)"
Example procedure with OUT:
CREATE PROCEDURE compute_totals(
IN customer_id bigint,
OUT order_count integer,
OUT total_cents bigint
)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT count(*), sum(amount_cents)
INTO order_count, total_cents
FROM orders
WHERE customer_id = compute_totals.customer_id;
END;
$$;
-- psql:
CALL compute_totals(42, NULL, NULL);
order_count | total_cents
-------------+-------------
17 | 284990Same as functions — DEFAULT expr or = expr. Trailing arguments with defaults may be omitted at CALL-site:
CREATE PROCEDURE archive_orders(cutoff date DEFAULT current_date - 90)
LANGUAGE sql
AS $$
DELETE FROM orders WHERE created_at < cutoff;
$$;
CALL archive_orders(); -- uses default
CALL archive_orders('2026-01-01'); -- overrideSame flag meaning as functions: SECURITY INVOKER (default) runs as the caller; SECURITY DEFINER runs as the owner.
[!WARNING] SECURITY DEFINER blocks COMMIT If you mark a procedure
SECURITY DEFINER, anyCOMMITorROLLBACKinside it will fail at runtime withinvalid transaction termination. The grammar accepts the combination atCREATE PROCEDUREtime; the failure surfaces when you actuallyCALLit. To get both privileged execution and mid-procedureCOMMIT, use a two-procedure pattern: a smallSECURITY DEFINERouter procedure for privileged setup, and a separate non-SECURITY DEFINERworker procedure for the transactional loop.
SET parameter = value clauses on the procedure (e.g., SET search_path = 'public, audit', SET work_mem = '64MB') install a per-procedure GUC that is reverted at procedure exit.
Like SECURITY DEFINER, attaching a SET clause disables transaction control inside the procedure4:
"If a
SETclause is attached to a procedure, then that procedure cannot execute transaction control statements (for example,COMMITandROLLBACK, depending on the language)."
This is the single most surprising procedure restriction. If you need a chunked-loop procedure that also pins search_path, do the SET inside the procedure body (e.g., PERFORM set_config('search_path', 'audit, public', false); once at entry) instead of using a SET clause on CREATE PROCEDURE.
There is a subtle interaction with SET LOCAL from within the procedure4:
"If a
SETclause is attached to a procedure, then the effects of aSET LOCALcommand executed inside the procedure for the same variable are restricted to the procedure: the configuration parameter's prior value is still restored at procedure exit."
[!NOTE] PostgreSQL 14 SQL-language procedures (and SQL-language functions) can use the SQL-standard
BEGIN ATOMIC ... END;form for the body, which parses the statements at definition time and tracks dependencies inpg_depend. The pre-PG14 form isAS $$ ... $$with the body re-parsed at every call. See06-functions.mdfor the full rules.
Example:
CREATE PROCEDURE bump_versions(table_name regclass)
LANGUAGE sql
BEGIN ATOMIC
UPDATE table_name SET version = version + 1;
END;BEGIN ATOMIC SQL procedures cannot use COMMIT / ROLLBACK (no procedural control flow). They are a clean choice for grouping a fixed sequence of DML statements behind a CALL.
Full syntax from the docs7:
ALTER PROCEDURE name [ ( argspec ) ]
RENAME TO new_name
| OWNER TO { new_owner | CURRENT_ROLE | CURRENT_USER | SESSION_USER }
| SET SCHEMA new_schema
| [ NO ] DEPENDS ON EXTENSION extension_name
| <action> [ ... ] [ RESTRICT ]where <action> is one of:
[ EXTERNAL ] SECURITY INVOKER | [ EXTERNAL ] SECURITY DEFINER
SET configuration_parameter { TO | = } { value | DEFAULT }
SET configuration_parameter FROM CURRENT
RESET configuration_parameter
RESET ALLThings you can do with ALTER PROCEDURE:
ALTER PROCEDURE proc(int) RENAME TO new_proc;ALTER PROCEDURE proc(int) OWNER TO new_owner;ALTER PROCEDURE proc(int) SET SCHEMA private;ALTER PROCEDURE proc(int) SECURITY DEFINER;ALTER PROCEDURE proc(int) SET search_path = audit, pg_temp;ALTER PROCEDURE proc(int) DEPENDS ON EXTENSION my_ext;Things you cannot change with ALTER PROCEDURE:
CREATE OR REPLACE PROCEDURE.DROP PROCEDURE name [ ( argspec ) ] [ CASCADE | RESTRICT ] removes the procedure; RESTRICT (default) errors if anything depends on it.
CALL name ( [ argument ] [, ...] )Named notation:
CALL compute_totals(customer_id => 42, order_count => NULL, total_cents => NULL);The result of CALL when there are OUT / INOUT parameters is a single row containing those parameter values6:
"If the procedure has any output parameters, then a result row will be returned, containing the values of those parameters."
And the headline transaction-context rule6:
"If
CALLis executed in a transaction block, then the called procedure cannot execute transaction control statements. Transaction control statements are only allowed ifCALLis executed in its own transaction."
This means: from the psql top level (autocommit on), a CALL runs in its own transaction and the procedure can COMMIT. But if you wrap it in BEGIN; CALL ...; COMMIT;, the inner COMMIT inside the procedure will fail.
Inside PL/pgSQL, you call a procedure with CALL, capturing OUT / INOUT values via INTO:
DO $$
DECLARE
cnt integer;
total bigint;
BEGIN
CALL compute_totals(42, cnt, total);
RAISE NOTICE 'cnt=% total=%', cnt, total;
END;
$$;PL/pgSQL handles output parameters in CALL commands differently6 — specifically, it expects local variables of matching modes/types where you'd write NULL from SQL.
Nested procedures may still commit — the PL/pgSQL transaction rule3:
"Transaction control is only possible in
CALLorDOinvocations from the top level or nestedCALLorDOinvocations without any other intervening command."
So CALL outer() → CALL inner() → COMMIT; works. But CALL outer() → SELECT helper_fn() → CALL inner() → COMMIT; does not: the SELECT in the middle establishes a function-call context that pins the snapshot. The procedure underneath can no longer commit.
From the user-defined procedures chapter2:
"Certain function attributes, such as strictness, don't apply to procedures. Those attributes control how the function is used in a query, which isn't relevant to procedures."
Concretely, procedures do not accept these attributes that functions accept:
IMMUTABLE / STABLE / VOLATILE — not applicable; procedures aren't called inside expressions.STRICT / CALLED ON NULL INPUT — same reason.LEAKPROOF — irrelevant; procedures don't appear in query predicates.PARALLEL { UNSAFE | RESTRICTED | SAFE } — procedures can't be in a parallel plan.COST / ROWS — these tune the planner; procedures aren't planned-inside-a-query.Attempting to add these to CREATE PROCEDURE is rejected at parse time.
Just like functions, new procedures get EXECUTE granted to PUBLIC by default. This is the single biggest privilege-escalation footgun for SECURITY DEFINER procedures.
The right hardening boilerplate is the same as for functions8:
CREATE OR REPLACE PROCEDURE audit.archive_orders(cutoff date)
SECURITY DEFINER
SET search_path = audit, pg_catalog
LANGUAGE sql
AS $$
DELETE FROM audit.orders WHERE created_at < cutoff;
$$;
REVOKE EXECUTE ON PROCEDURE audit.archive_orders(date) FROM PUBLIC;
GRANT EXECUTE ON PROCEDURE audit.archive_orders(date) TO ops_admin;For new schemas, prefer ALTER DEFAULT PRIVILEGES IN SCHEMA audit REVOKE EXECUTE ON ROUTINES FROM PUBLIC; so the next procedure created in that schema is hardened too. See 46-roles-privileges.md for the default-privileges machinery.
Same as functions. Procedures live in pg_proc (the same catalog), and the lock matrix is identical.
| Operation | Lock taken |
|---|---|
CREATE PROCEDURE, CREATE OR REPLACE PROCEDURE | RowExclusiveLock on pg_proc |
ALTER PROCEDURE ... RENAME / OWNER / SET SCHEMA / SECURITY DEFINER / SET / DEPENDS ON EXTENSION | AccessExclusiveLock on the procedure row |
DROP PROCEDURE | AccessExclusiveLock on the procedure row + cascade locks |
GRANT / REVOKE on procedure | AccessShareLock on the procedure row + RowExclusiveLock on pg_proc |
See 43-locking.md for the full table-level matrix.
In a PL/pgSQL procedure body (and the equivalents in other procedural languages):
| Command | Effect |
|---|---|
COMMIT; | end current transaction, start a new one with default characteristics |
ROLLBACK; | abort current transaction, start a new one with default characteristics |
COMMIT AND CHAIN; | end current transaction, start a new one with the same characteristics (isolation level, read-only, deferrable) |
ROLLBACK AND CHAIN; | abort current transaction, start a new one with the same characteristics |
A new transaction starts automatically after COMMIT or ROLLBACK — you do not issue START TRANSACTION / BEGIN. (In PL/pgSQL, BEGIN and END are block delimiters; they are not transaction-control keywords.3)
From the manual3, verbatim:
"Transaction control is only possible in
CALLorDOinvocations from the top level or nestedCALLorDOinvocations without any other intervening command."
Concretely, all of these throw invalid transaction termination at runtime:
COMMIT inside a function. Functions are evaluated inside an expression context — there is no way to break that context.COMMIT inside a SECURITY DEFINER procedure. The owner-context save would be torn by the transaction boundary.COMMIT inside a procedure with a SET clause. Same reason.COMMIT inside a procedure called by SELECT proc_caller() where proc_caller() is a function. The SELECT establishes a function-call snapshot.COMMIT inside an EXCEPTION block. PL/pgSQL exception handling is implemented as a subtransaction (a SAVEPOINT + auto-rollback on raise) — committing the outer transaction would orphan the subtransaction. Per the docs3: "A transaction cannot be ended inside a block with exception handlers."COMMIT inside a FOR row IN UPDATE ... RETURNING ... LOOP. The non-read-only cursor would lose its row-stream state. Per the docs3: "Transaction commands are not allowed in cursor loops driven by commands that are not read-only (for example UPDATE ... RETURNING)."CALL proc() inside a BEGIN; ... COMMIT; block issued by the client — CALL must be its own top-level statement.6[!WARNING] EXCEPTION blocks create subtransactions Every PL/pgSQL block that has an
EXCEPTIONclause is implemented as a subtransaction. This (a) forbids transaction control inside the block, (b) bumps thesubtransSLRU on every entry, and (c) is the most common silent performance regression in chunked-loop procedures that wrap each iteration inBEGIN ... EXCEPTION ... END. See41-transactions.mdfor subtransaction cost.
A COMMIT (or ROLLBACK) inside a procedure ends the current transaction. The next statement in the procedure runs in a brand-new transaction with a fresh snapshot. That has visibility consequences:
COMMIT may now be visible from concurrent writers that committed in the gap.FOR UPDATE, advisory pg_advisory_xact_lock, table-level locks) are released at COMMIT. If you need those locks across the loop, use pg_advisory_lock (session-scoped — see 44-advisory-locks.md) or re-acquire them.If you want the new transaction to inherit characteristics like ISOLATION LEVEL REPEATABLE READ from the previous one, use COMMIT AND CHAIN. Otherwise the new transaction starts with default_transaction_isolation (typically read committed).
A frequent procedure pattern is FOR row IN SELECT ... FROM ... ORDER BY ... LOOP ... COMMIT; END LOOP;. PostgreSQL automatically converts that cursor to a holdable cursor on the first COMMIT, with a side effect described by the docs3:
"Normally, cursors are automatically closed at transaction commit. However, a cursor created as part of a loop like this is automatically converted to a holdable cursor by the first
COMMITorROLLBACK. That means that the cursor is fully evaluated at the firstCOMMITorROLLBACKrather than row by row."
Consequence: the entire result set is materialized in memory at the first commit. For a 50-million-row loop, that is not what you want. The right pattern is to drive the loop by a WHERE ... LIMIT N query that's re-run each iteration, not by an open cursor — see Recipe 1 below.
The canonical "delete N million rows without blowing up WAL / autovacuum / lock waits" pattern. Drive the loop with a re-issued DELETE ... WHERE id IN (... LIMIT 1000) RETURNING 1 so each commit releases locks and starts a fresh transaction with a fresh snapshot:
CREATE PROCEDURE archive_old_orders(cutoff timestamptz, batch_size int DEFAULT 1000)
LANGUAGE plpgsql
AS $$
DECLARE
deleted integer;
BEGIN
LOOP
WITH victims AS (
SELECT id
FROM orders
WHERE created_at < cutoff
ORDER BY id
LIMIT batch_size
FOR UPDATE SKIP LOCKED
)
DELETE FROM orders
WHERE id IN (SELECT id FROM victims);
GET DIAGNOSTICS deleted = ROW_COUNT;
EXIT WHEN deleted = 0;
COMMIT;
PERFORM pg_sleep(0.05); -- yield briefly between batches
END LOOP;
END;
$$;
CALL archive_old_orders('2024-01-01'::timestamptz);Why this shape: each iteration is its own transaction, so vacuum can reclaim space between batches, FOR UPDATE SKIP LOCKED lets concurrent updaters proceed, and the LIMIT keeps lock count predictable.
[!WARNING] Do not wrap the LOOP body in BEGIN ... EXCEPTION Adding
EXCEPTION WHEN OTHERS THEN ...around the iteration disablesCOMMITinside the loop (subtransaction rule). If you need error handling, log viaRAISE LOGfrom outside an exception block, or use a wrapper procedure that calls this one and catches at that layer.
[!NOTE] PostgreSQL 14 Procedures gained
OUTparameters in PG14. Pre-PG14, useINOUTinstead.
CREATE PROCEDURE next_invoice_number(
OUT new_number bigint,
OUT issued_at timestamptz
)
LANGUAGE plpgsql
AS $$
BEGIN
SELECT nextval('invoice_seq'), clock_timestamp()
INTO new_number, issued_at;
END;
$$;
CALL next_invoice_number(NULL, NULL);
new_number | issued_at
------------+------------------------------
123 | 2026-05-11 07:50:12.345+00Useful when each step is independent and you want crash-safe progress (a crash mid-procedure should not roll back the earlier steps):
CREATE PROCEDURE nightly_maintenance()
LANGUAGE plpgsql
AS $$
BEGIN
RAISE LOG 'maintenance: step 1 — refresh dashboards';
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sales_daily;
COMMIT;
RAISE LOG 'maintenance: step 2 — vacuum hot table';
VACUUM (ANALYZE) orders;
COMMIT;
RAISE LOG 'maintenance: step 3 — roll partitions';
CALL partition_admin.rotate_orders(retention_days => 90);
COMMIT;
RAISE LOG 'maintenance: done';
END;
$$;(Note: VACUUM cannot run inside a transaction block — but in a procedure, between commits, each statement is its own top-level transaction, so VACUUM works. This is one of the strongest reasons to use a procedure over a script of psql -c '...' calls.)
If your loop body relies on REPEATABLE READ, use COMMIT AND CHAIN so the next iteration inherits the isolation level instead of falling back to read committed:
CREATE PROCEDURE consistent_export()
LANGUAGE plpgsql
AS $$
BEGIN
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
FOR partition_name IN
SELECT inhrelid::regclass::text
FROM pg_inherits
WHERE inhparent = 'orders'::regclass
ORDER BY 1
LOOP
-- ... export this partition snapshotted ...
EXECUTE format('COPY %I TO ''/exports/%I.csv'' CSV', partition_name, partition_name);
COMMIT AND CHAIN;
END LOOP;
END;
$$;Hardening pattern for a privileged procedure. Note: this version cannot use COMMIT because of the SECURITY DEFINER restriction:
CREATE OR REPLACE PROCEDURE audit.record_login(p_user bigint, p_ip inet)
SECURITY DEFINER
SET search_path = audit, pg_catalog
LANGUAGE sql
AS $$
INSERT INTO audit.logins(user_id, ip, at) VALUES (p_user, p_ip, now());
$$;
REVOKE EXECUTE ON PROCEDURE audit.record_login(bigint, inet) FROM PUBLIC;
GRANT EXECUTE ON PROCEDURE audit.record_login(bigint, inet) TO app_role;Two-procedure pattern for SECURITY DEFINER + COMMIT (see WARNING above):
-- non-SECURITY-DEFINER worker can commit; runs as caller
CREATE PROCEDURE archive_worker(...)
LANGUAGE plpgsql AS $$ ... COMMIT; ... $$;
-- thin SECURITY DEFINER wrapper for privilege escalation;
-- does NOT call COMMIT and does NOT have a SET clause that would block one in the worker.
-- (We must set search_path inline instead of via SET clause to avoid the restriction.)
CREATE OR REPLACE PROCEDURE archive_admin(...)
SECURITY DEFINER
LANGUAGE plpgsql AS $$
BEGIN
PERFORM set_config('search_path', 'audit, pg_catalog', true); -- transaction-local
CALL archive_worker(...); -- still no COMMIT here either
END;
$$;The wrapper cannot COMMIT either — SECURITY DEFINER and chunked COMMIT are incompatible inside a single procedure. The caller must invoke the worker directly with appropriate privileges.
A clean architecture is: pure functions do the read-only / scalar work; one procedure orchestrates the writes and commits.
CREATE FUNCTION compute_invoice_total(p_invoice bigint)
RETURNS numeric
LANGUAGE sql STABLE AS $$
SELECT sum(unit_price * qty)
FROM invoice_items
WHERE invoice_id = p_invoice;
$$;
CREATE PROCEDURE close_invoice(p_invoice bigint)
LANGUAGE plpgsql AS $$
DECLARE
total numeric;
BEGIN
total := compute_invoice_total(p_invoice); -- function: no commit needed
UPDATE invoices SET total_amount = total, closed_at = now()
WHERE id = p_invoice;
INSERT INTO audit.invoice_closures(invoice_id, total) VALUES (p_invoice, total);
COMMIT;
END;
$$;Combine with pg_cron for nightly scheduling. Each operation gets its own transaction so a crash mid-rotation leaves a clean state:
CREATE PROCEDURE rotate_orders_partitions(retention_days int DEFAULT 90)
LANGUAGE plpgsql AS $$
DECLARE
next_day date := (current_date + interval '1 day')::date;
old_day date := (current_date - retention_days * interval '1 day')::date;
new_part text := format('orders_%s', to_char(next_day, 'YYYYMMDD'));
old_part text := format('orders_%s', to_char(old_day, 'YYYYMMDD'));
BEGIN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
new_part, next_day, next_day + 1
);
COMMIT;
IF EXISTS (SELECT 1 FROM pg_class WHERE relname = old_part) THEN
EXECUTE format('ALTER TABLE orders DETACH PARTITION %I CONCURRENTLY', old_part);
COMMIT;
EXECUTE format('DROP TABLE %I', old_part);
COMMIT;
END IF;
END;
$$;Schedule with pg_cron:
SELECT cron.schedule('rotate-orders', '5 2 * * *',
$$CALL rotate_orders_partitions(90)$$);CREATE OR REPLACE PROCEDURE is the safer default — it preserves dependencies and grants, where DROP + CREATE would silently drop them. Pattern:
CREATE OR REPLACE PROCEDURE app.sync_users()
LANGUAGE plpgsql AS $$
BEGIN
-- body
END;
$$;
DO $$
BEGIN
-- (Re-)grant; safe to re-run.
REVOKE EXECUTE ON PROCEDURE app.sync_users() FROM PUBLIC;
GRANT EXECUTE ON PROCEDURE app.sync_users() TO etl_role;
END;
$$;If you need to change argument types, DROP PROCEDURE app.sync_users(...); is unavoidable — and you must re-GRANT afterwards. Take an inventory of grants from pg_proc.proacl before dropping.
From psql (autocommit on), this works:
\c mydb
CALL archive_old_orders('2024-01-01');But this fails with invalid transaction termination:
BEGIN;
CALL archive_old_orders('2024-01-01'); -- ERROR inside the procedure on first COMMIT
COMMIT;And this is rejected at runtime too, because the surrounding SELECT establishes a snapshot the procedure cannot break out of:
CREATE FUNCTION trigger_archive() RETURNS void
LANGUAGE plpgsql AS $$
BEGIN
CALL archive_old_orders('2024-01-01'); -- ERROR: cannot CALL a procedure that
-- does transaction control from a function
END;
$$;The right way to "call a procedure from a function" is: don't. Refactor the function into the procedure (or vice versa), or invoke the procedure separately from the application layer / pg_cron.
Procedures live in pg_proc like functions, distinguished by prokind = 'p'. (For functions, prokind = 'f'; aggregates 'a'; window functions 'w'.)
SELECT
n.nspname AS schema,
p.proname AS name,
pg_get_function_arguments(p.oid) AS args,
l.lanname AS language,
p.prosecdef AS security_definer,
p.proconfig AS attached_settings,
pg_get_userbyid(p.proowner) AS owner,
obj_description(p.oid, 'pg_proc') AS comment
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
JOIN pg_language l ON l.oid = p.prolang
WHERE p.prokind = 'p'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2;For the audit script that finds every SECURITY DEFINER procedure missing a pinned search_path, see 06-functions.md — the same query works against prokind IN ('f','p') to cover both.
Trying to COMMIT inside a function. Functions cannot end the transaction they're embedded in. If you find yourself wanting to, refactor to a procedure.
SECURITY DEFINER + COMMIT in the same procedure. Grammatically accepted; fails at runtime. Choose one. If you must have both, escalate privilege at the outer call site (e.g., a security-definer thin wrapper that does nothing requiring commit) and put the commit loop in a separate non-SECURITY DEFINER procedure granted to a privileged role.
SET search_path = '...' clause on a chunked-COMMIT procedure. Same problem as #2. Use PERFORM set_config('search_path', '...', true) at the top of the body instead.
EXCEPTION block in the loop body. The exception handler creates a subtransaction (SAVEPOINT), which disables COMMIT inside the block and adds subtrans SLRU pressure. If you need per-iteration error handling, do it at the calling layer or use a savepoint-free strategy like inspecting GET DIAGNOSTICS after each UPDATE.
Holdable-cursor materialization. FOR row IN SELECT ... LOOP COMMIT; END LOOP; does not stream row-by-row after the first commit — the cursor is fully materialized in memory. For large sets, drive the loop with LIMIT N re-issued each iteration, not by a FOR row IN ... cursor.
Snapshot reset after COMMIT. Code that reads a value pre-COMMIT and assumes it's still current post-COMMIT is wrong; concurrent writers may have committed in between. Re-read inside each transaction.
Locks released at every COMMIT. Row locks (FOR UPDATE), advisory transaction locks, and table locks are all dropped on COMMIT. If you need cross-iteration mutual exclusion, use pg_advisory_lock (session scope) once at the top of the procedure and pg_advisory_unlock at the bottom.
CALL inside a BEGIN; ... COMMIT; from the client. The procedure cannot commit because it's nested in an explicit transaction block. The caller must run CALL outside an open transaction.
Procedures + connection pooling in transaction mode. Procedures often hold a backend across multiple transactions (one per commit cycle). Transaction-pooled connections (pgBouncer pool_mode = transaction) will rotate the underlying backend at every commit, breaking session-scoped advisory locks and SET LOCAL. For procedures that commit, use session-pool mode or call them from a dedicated worker, not from the request-path pool. See 81-pgbouncer.md.
SECURITY DEFINER without REVOKE EXECUTE ... FROM PUBLIC. Same gotcha as functions: PUBLIC gets EXECUTE by default. A SECURITY DEFINER procedure that you intended only for one role is, by default, callable by anyone in the database. Always revoke PUBLIC and grant explicitly.
DROP PROCEDURE losing grants. CREATE OR REPLACE PROCEDURE preserves grants and ownership; DROP + CREATE does not. Prefer replace when the argument signature is unchanged.
Using a procedure when a function would do. If the routine has no commits, no transactional side effects across statements, and just computes-and-returns, write it as a function. Functions are usable in SELECT lists, can be IMMUTABLE (and thus indexed), and inline when the SQL form allows. Procedures cannot.
Confusing PL/pgSQL BEGIN / END with transaction BEGIN / COMMIT. PL/pgSQL BEGIN opens a code block (with optional EXCEPTION handler), not a transaction. BEGIN; COMMIT; is invalid inside a PL/pgSQL body. The transaction-control commands are COMMIT; and ROLLBACK; (and their AND CHAIN forms).
Forgetting that BEGIN ATOMIC SQL procedures cannot commit. They have no procedural control flow. BEGIN ATOMIC is for grouping a small fixed sequence of DML. For commit-loop work, use LANGUAGE plpgsql.
ALTER PROCEDURE cannot change the body or argument types. Use CREATE OR REPLACE PROCEDURE for body changes; drop and recreate for signature changes. There is no ALTER PROCEDURE ... BODY syntax.
06-functions.md — CREATE FUNCTION, volatility, parallel safety, SECURITY DEFINER hardening for functions, polymorphic types, SQL-function inlining.08-plpgsql.md — PL/pgSQL block grammar, control flow (IF / CASE / LOOP / FOR / WHILE), exception handling, dynamic SQL, cursors.10-dynamic-sql.md — EXECUTE, format(), injection prevention for dynamic SQL inside procedure bodies.09-procedural-languages.md — PL/Perl, PL/Python, PL/Tcl — and the trust/untrust split.13-cursors-and-prepares.md — DECLARE CURSOR, holdable cursors, the cursor-loop behavior referenced above.35-partitioning.md — partition rotation patterns called by Recipe 7.41-transactions.md — BEGIN/COMMIT/ROLLBACK, savepoints, subtransactions, autocommit semantics, idle_in_transaction_session_timeout.42-isolation-levels.md — READ COMMITTED vs REPEATABLE READ vs SERIALIZABLE; relevant to COMMIT AND CHAIN.43-locking.md — full lock matrix; relevant to "locks released at every COMMIT" gotcha.44-advisory-locks.md — session-scoped advisory locks survive COMMIT, transaction-scoped don't.46-roles-privileges.md — ALTER DEFAULT PRIVILEGES, REVOKE EXECUTE from PUBLIC.64-system-catalogs.md — pg_proc.prokind, proconfig, prosecdef.81-pgbouncer.md — procedure interaction with transaction-mode pooling.98-pg-cron.md — scheduling CALL on a cron.102-skill-cookbook.md — the chunked-DML and partition-rotation recipes generalized.PostgreSQL 14 release notes. "Allow procedures to have OUT parameters (Peter Eisentraut)." Also in the overview: "Stored procedures can now return data via OUT parameters." https://www.postgresql.org/docs/release/14.0/ ↩ ↩2 ↩3 ↩4
PostgreSQL 16, "38.4. User-Defined Procedures". Includes the verbatim function/procedure distinctions "A procedure can commit or roll back transactions during its execution (then automatically beginning a new transaction), so long as the invoking CALL command is not part of an explicit transaction block. A function cannot do that." and "Certain function attributes, such as strictness, don't apply to procedures. Those attributes control how the function is used in a query, which isn't relevant to procedures." https://www.postgresql.org/docs/16/xproc.html ↩ ↩2 ↩3 ↩4
PostgreSQL 16, "43.8. Transaction Management" (PL/pgSQL chapter). Includes the verbatim restrictions "Transaction control is only possible in CALL or DO invocations from the top level or nested CALL or DO invocations without any other intervening command.", "A transaction cannot be ended inside a block with exception handlers.", "Transaction commands are not allowed in cursor loops driven by commands that are not read-only (for example UPDATE ... RETURNING).", and the holdable-cursor materialization note "a cursor created as part of a loop like this is automatically converted to a holdable cursor by the first COMMIT or ROLLBACK. That means that the cursor is fully evaluated at the first COMMIT or ROLLBACK rather than row by row." https://www.postgresql.org/docs/16/plpgsql-transactions.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
PostgreSQL 16, CREATE PROCEDURE reference page. Includes the verbatim restriction "A SECURITY DEFINER procedure cannot execute transaction control statements (for example, COMMIT and ROLLBACK, depending on the language)." and "If a SET clause is attached to a procedure, then that procedure cannot execute transaction control statements (for example, COMMIT and ROLLBACK, depending on the language)." https://www.postgresql.org/docs/16/sql-createprocedure.html ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
PostgreSQL 11 release notes. "Add SQL-level procedures, which can start and commit their own transactions (Peter Eisentraut). They are created with the new CREATE PROCEDURE command and invoked via CALL." and "Add transaction control to PL/pgSQL, PL/Perl, PL/Python, PL/Tcl, and SPI server-side languages (Peter Eisentraut)." https://www.postgresql.org/docs/release/11.0/ ↩
PostgreSQL 16, CALL reference page. Includes the verbatim restriction "If CALL is executed in a transaction block, then the called procedure cannot execute transaction control statements. Transaction control statements are only allowed if CALL is executed in its own transaction." and the OUT-parameter handling note "Arguments must be supplied for all procedure parameters that lack defaults, including OUT parameters. However, arguments matching OUT parameters are not evaluated, so it's customary to write NULL for them." https://www.postgresql.org/docs/16/sql-call.html ↩ ↩2 ↩3 ↩4 ↩5
PostgreSQL 16, ALTER PROCEDURE reference page. Documents the supported actions: RENAME TO, OWNER TO, SET SCHEMA, SECURITY DEFINER/INVOKER, SET configuration_parameter, RESET, DEPENDS ON EXTENSION. https://www.postgresql.org/docs/16/sql-alterprocedure.html ↩
PostgreSQL 16, 06-functions.md recipe 3 (cross-reference): the SECURITY DEFINER + SET search_path + REVOKE EXECUTE FROM PUBLIC hardening triad. Primary doc reference: PostgreSQL 16, CREATE FUNCTION security section. https://www.postgresql.org/docs/16/sql-createfunction.html ↩
evals
references