Complete reference for all df.* functions with parameter types and auto-wrap behavior.
Auto-wrap means a plain SQL string is automatically converted to a df.sql() node.
-- These are equivalent when auto-wrap is supported:
df.seq('SELECT 1', 'SELECT 2')
df.seq(df.sql('SELECT 1'), df.sql('SELECT 2'))Parameters marked with ✅ Auto-wrap accept either:
- A plain SQL string (auto-wrapped to
df.sql()) - A Durofut node (from any
df.*function)
Parameters marked with ❌ Literal expect a literal value (not auto-wrapped).
Creates a SQL execution node.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
query |
TEXT | ❌ Literal | SQL query to execute |
df.sql('SELECT * FROM users WHERE id = 1')Executes two nodes in sequence.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
a |
TEXT | ✅ Auto-wrap | First node to execute |
b |
TEXT | ✅ Auto-wrap | Second node to execute |
df.seq('SELECT 1', 'SELECT 2')
'SELECT 1' ~> 'SELECT 2' -- operator form
df.sql('SELECT 1') ~> df.sleep(5) -- mixedBinds a result to a variable name.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
fut |
TEXT | ✅ Auto-wrap | Node whose result to name |
name |
TEXT | ❌ Literal | Variable name (no $ prefix) |
df.as('SELECT id FROM users LIMIT 1', 'user_id')
'SELECT id FROM users LIMIT 1' |=> 'user_id' -- operator formSubstitution patterns available on named results:
| Pattern | Behavior | On no rows | On NULL |
|---|---|---|---|
$name |
First column of first row | Error | Error |
$name.column |
Specific column of first row | Error | Error |
$name? |
Null-safe scalar | → NULL |
→ NULL |
$name.column? |
Null-safe column | → NULL |
→ NULL |
$name.* |
Row-set expansion (inline VALUES) | Empty relation | N/A |
Executes nodes in parallel, waits for all to complete.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
a |
TEXT | ✅ Auto-wrap | First parallel branch |
b |
TEXT | ✅ Auto-wrap | Second parallel branch |
df.join('SELECT count(*) FROM a', 'SELECT count(*) FROM b')
'SELECT 1' & 'SELECT 2' -- operator formExecutes three nodes in parallel, waits for all.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
a |
TEXT | ✅ Auto-wrap | First parallel branch |
b |
TEXT | ✅ Auto-wrap | Second parallel branch |
c |
TEXT | ✅ Auto-wrap | Third parallel branch |
df.join3('SELECT 1', 'SELECT 2', 'SELECT 3')Executes nodes in parallel, first to complete wins.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
a |
TEXT | ✅ Auto-wrap | First competing branch |
b |
TEXT | ✅ Auto-wrap | Second competing branch |
df.race(df.sleep(10), df.wait_for_signal('cancel'))
df.sleep(10) | df.wait_for_signal('cancel') -- operator formConditional execution.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
condition |
TEXT | ✅ Auto-wrap | Node that returns truthy/falsy |
then |
TEXT | ✅ Auto-wrap | Execute if condition is truthy |
else |
TEXT | ✅ Auto-wrap | Execute if condition is falsy |
df.if('SELECT count(*) > 0 FROM q', 'SELECT ''yes''', 'SELECT ''no''')
'SELECT true' ?> 'SELECT ''yes''' !> 'SELECT ''no''' -- operator formBranches based on whether a named result has any rows. Unlike df.if(), no SQL query is executed — the check is done in-memory on the stored result.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
result_name |
TEXT | ❌ Literal | Name of a previously stored result (no $ prefix) |
then |
TEXT | ✅ Auto-wrap | Execute if result has rows |
else |
TEXT | ✅ Auto-wrap | Execute if result has zero rows |
df.if_rows('data', 'SELECT $data.id', 'SELECT ''no data''')Experimental: The
continue_on_failuresyntax is subject to change in future releases.
Repeats body forever or while condition is true. The supported call shapes
are:
df.loop(body)
df.loop(body, condition)
df.loop(body, continue_on_failure => true)
df.loop(body, condition, continue_on_failure => true)| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
body |
TEXT | ✅ Auto-wrap | Node to repeat |
condition |
TEXT | ✅ Auto-wrap | (Optional) Evaluate after a successful body; continue while truthy |
continue_on_failure |
BOOLEAN | ❌ Literal | (Optional) Continue after a body activity failure; default false |
-- Infinite loop
df.loop('SELECT process_item()' ~> df.sleep(1))
-- While loop
df.loop('SELECT process_item()', 'SELECT count(*) > 0 FROM queue')
-- Infinite loop that continues after body activity failures
df.loop(
'SELECT process_item()' ~> df.sleep(1),
continue_on_failure => true
)
-- Conditional loop that continues after body activity failures
df.loop(
'SELECT process_item()',
'SELECT count(*) > 0 FROM queue',
continue_on_failure => true
)By default, and when continue_on_failure is false, loop execution is
fail-fast. When it is true, each body iteration runs in a child
orchestration. After a successful body iteration, the child's results are
merged into the parent result map before condition evaluation. A consumed
typed application failure from a body activity skips condition evaluation and
starts the next iteration. This includes any error returned by a body SQL,
HTTP, or multipart activity, such as a query, authorization, connection, or
network error. Condition failures, malformed graph or child data, unrecognized
child errors, child-ID collisions, and orchestration/runtime failures remain
fatal.
The @> operator remains an infinite, fail-fast loop:
@> ('SELECT process_item()' ~> df.sleep(1))Exits the enclosing loop.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
value |
TEXT | ❌ Literal | (Optional) JSON value to return |
df.break() -- exit with null
df.break('{"status": "done"}') -- exit with valueNote: The value parameter is a literal JSON string, NOT auto-wrapped.
Pauses execution for N seconds.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
seconds |
INTEGER | ❌ Literal | Duration in seconds |
df.sleep(60)Waits until cron expression matches.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
cron_expr |
TEXT | ❌ Literal | 5-part cron expression |
df.wait_for_schedule('*/5 * * * *') -- every 5 minutes
df.wait_for_schedule('0 9 * * 1-5') -- weekdays at 9amWaits for an external signal.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
name |
TEXT | ❌ Literal | Signal name to wait for |
timeout |
INTEGER | ❌ Literal | (Optional) Timeout in seconds |
df.wait_for_signal('approval') -- wait forever
df.wait_for_signal('approval', 3600) -- 1 hour timeoutMakes an HTTP request.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
url |
TEXT | ❌ Literal | Request URL (supports $var substitution) |
method |
TEXT | ❌ Literal | HTTP method (default: POST) |
body |
TEXT | ❌ Literal | Request body JSON (supports $var) |
headers |
JSONB | ❌ Literal | Request headers |
timeout |
INTEGER | ❌ Literal | Timeout in seconds (default: 30) |
df.http('https://api.example.com/users', 'GET')
df.http('https://api.example.com', 'POST', '{"key": "$value"}')
df.http(url, 'GET', NULL, '{"Auth": "Bearer token"}'::jsonb, 60)Returns a JSON envelope: status, body, encoding, headers, ok, duration_ms.
Address its fields directly with dot notation:
df.http('https://api.example.com/thing', 'GET') |=> 'resp'
~> df.if('SELECT $resp.ok', 'SELECT ''hooray''', 'SELECT ''oh no''')encoding is text when the response Content-Type is textual, and base64 when it is
not — in which case body holds the base64 of the raw bytes. See
df.http_multipart for feeding those
bytes into a subsequent upload.
Makes an HTTP request with a multipart/form-data body. Requires the same
include_http => true grant as df.http().
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
url |
TEXT | ❌ Literal | Request URL (supports $var substitution) |
method |
TEXT | ❌ Literal | HTTP method (default: POST) |
parts |
JSONB | ❌ Literal | Array of part objects (see below) |
headers |
JSONB | ❌ Literal | Request headers |
timeout |
INTEGER | ❌ Literal | Timeout in seconds (default: 30) |
Each element of parts is an object:
| Key | Required | Description |
|---|---|---|
name |
✅ | Form field name (supports $var) |
data_b64 |
✅ | Part content, base64-encoded |
filename |
❌ | Sets the part's filename, making it a file upload (supports $var) |
content_type |
❌ | Part Content-Type |
data_b64 accepts base64 with embedded whitespace, so PostgreSQL's
encode(bytea, 'base64') output — which wraps at 76 columns — can be used directly.
df.http_multipart(
'https://api.example.com/upload', 'POST',
jsonb_build_array(
jsonb_build_object(
'name', 'file', 'filename', 'report.pdf',
'content_type', 'application/pdf',
'data_b64', encode(pg_read_binary_file('report.pdf'), 'base64'))),
'{"Authorization": "Bearer token"}'::jsonb, 60)data_b64 substitution is whole-value only. A reference must be the entire value:
'data_b64', '$speech.body' -- ✅ substituted
'data_b64', 'prefix$speech' -- ❌ error: mixes a reference with other textThis is deliberate. Splicing a variable into the middle of a base64 string cannot produce
valid base64, so pg_durable reports it as a node failure rather than sending a corrupt
part. Other fields (url, name, filename, headers) interpolate normally.
Returns the same envelope as df.http().
Instance APIs use eight-character local IDs in the database where the workflow
was started. Status, result, explain, signal, cancel, await, and listing operations
authorize against local df metadata/RLS, while engine operations use the shared
control store. Variables and HTTP grants are also origin-local. Satellite engine
IDs are internally namespaced; callers continue to pass the short local ID.
Starts a durable function.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
fut |
TEXT | ✅ Auto-wrap | Root node of the function |
label |
TEXT | ❌ Literal | (Optional) Human-readable label |
database |
TEXT | ❌ Literal | (Optional) SQL target on the cluster; omitted/NULL defaults to the origin database where df.start() is called |
transaction_mode |
TEXT | ❌ Literal | (Optional) 'caller' (default) or 'new' |
df.start('SELECT 1') -- auto-wrapped
df.start(df.sleep(10) ~> 'SELECT 2') -- explicit nodes
df.start('SELECT 1', 'my-job') -- with labelAll SQL nodes share the selected execution database; metadata and captured
variables stay in the origin. The target does not need pg_durable unless the SQL
uses its APIs. SQL executes as captured current_user, with that role's target
database privileges. Install in pg_durable.database first and wait for control
readiness before creating satellites; see
Multi-Database Support.
Selects which transaction the start itself runs in. It changes nothing about the durable function that gets started.
'caller'(default) — the start joins the caller's transaction, so aROLLBACKdiscards the durable function along with everything else.'new'— the start runs in its own transaction on a separate PostgreSQL session in the caller's database, regardless of the SQL target. It commits independently and survives a rollback of the caller's transaction. This provides the same rollback-survival outcome as an Oracle autonomous transaction for asynchronously started work. It is not a synchronous autonomous routine: the returned ID confirms the launch, while completion and execution errors are observed through monitoring APIs.
BEGIN;
INSERT INTO employees (id, name) VALUES (999, 'Test User');
SELECT df.start('INSERT INTO audit_log (message) VALUES (''x'')', 'audit',
transaction_mode => 'new');
ROLLBACK; -- the employees insert is undone; the durable function still runsAn unrecognised value raises an error rather than falling back to the default.
Note: under
'new'the separate session sees only committed rows, so the captureddf.varssnapshot excludes variables set earlier in the caller's open transaction. Each admitted call also opens an extra backend connection, capped per database by advisory admission locks usingpg_durable.max_new_transaction_starts(default2); extra callers wait up topg_durable.new_transaction_start_timeoutseconds (default5) before failing without opening the loopback session. The innerdf.start()statement itself is still bounded by a 30 slock_timeout/statement_timeout.'new'is rejected inside a workflow, where a plaindf.start()is already independent of any caller transaction. Avoid per-row triggers and other high-fan-out call sites unless you have validated them against that admission cap, and make target operations idempotent because a connection failure can make launch outcome uncertain. See Transaction Semantics for details. Neither mode provides cross-database atomicity with the control store or SQL target.
Sends a signal to a running instance.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
instance_id |
TEXT | ❌ Literal | Target instance ID |
signal_name |
TEXT | ❌ Literal | Signal name |
signal_data |
TEXT | ❌ Literal | Optional signal payload text (default: '{}'). Valid JSON is preserved; other text is sent as a JSON string. |
df.signal('a1b2c3d4', 'approval', '{"approved": true}')Cancels a running instance.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
instance_id |
TEXT | ❌ Literal | Target instance ID |
reason |
TEXT | ❌ Literal | Cancellation reason |
df.cancel('a1b2c3d4', 'Manual stop')Gets instance status.
Note: the argument is an
instance_id(returned bydf.start()), not a label. Passing a label returnsNULL, since no instance has that ID. To check a labeled run, resolve the label to aninstance_idfirst (see example below).
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
instance_id |
TEXT | ❌ Literal | Target instance ID from df.start() (not a label) |
-- By instance_id. Returns a lowercase status:
-- 'pending', 'running', 'completed', 'failed', or 'cancelled'.
SELECT df.status('a1b2c3d4');
-- Have a label instead of an instance_id? Resolve it first:
SELECT df.status(instance_id)
FROM df.list_instances()
WHERE label = 'my-job';If you reuse a label across runs, multiple instances can match — pass the specific instance_id you want.
Lists your durable function instances, newest-first. Results are RLS-scoped to your own instances (superusers see all). The function comes in two overloads, distinguished by argument count:
| Overload | Call shape | Returned columns |
|---|---|---|
| Basic (0–2 args) | df.list_instances([status_filter[, limit_count]]) |
6 columns (no timestamps or cursor) |
| Paginated (3–4 args) | df.list_instances(status_filter, limit_count, label_filter[, after_cursor]) |
9 columns (adds created_at, completed_at, next_cursor) |
The two overloads have non-overlapping arities (basic matches 0–2 arguments, paginated matches 3–4), so a call is never ambiguous. To reach the paginated overload you must pass at least the first three arguments — use NULL for any you don't want to filter on (e.g. df.list_instances(NULL, 100, NULL)).
| Parameter | Type | Default | Description |
|---|---|---|---|
status_filter |
TEXT | NULL (basic only) |
Only instances with this status (lowercase: pending, running, completed, failed, cancelled). NULL = any. |
limit_count |
INTEGER | 100 (basic only) |
Max rows per page (must be ≥ 1). A request above pg_durable.list_instances_max_limit (default 1000) raises an error instead of being silently truncated — lower limit_count, or use the paginated overload (after_cursor) for larger result sets. |
label_filter |
TEXT | — (required to select the paginated overload) | Only instances whose label equals this value (issue #87). NULL = any. |
after_cursor |
TEXT | NULL |
Opaque keyset cursor from a prior page's next_cursor; returns the page that sorts strictly after it (issue #146). NULL = first page. |
status_filterandlimit_countdefault only in the basic overload. The paginated overload requires all three ofstatus_filter,limit_count, andlabel_filterto be supplied positionally (passNULLto skip a filter); onlyafter_cursoris optional.
Basic overload columns: instance_id, label, function_name, status, execution_count, output.
Paginated overload columns: the six above plus created_at, completed_at, next_cursor.
created_at/completed_atare the submit and completion timestamps fromdf.instances.completed_atisNULLuntil the instance reachescompleted(it staysNULLforfailed/cancelled).- Rows are ordered
created_at DESC, id ASC(deterministic, served by the(created_at DESC, id)indexes ondf.instances). next_cursoris the token to fetch the page after this one. It is the same value on every row of a page andNULLon the final page.
-- Basic overload: most recent 50 completed runs (6 columns, no timestamps/cursor)
SELECT instance_id, status FROM df.list_instances('completed', 50);
-- Paginated overload: all instances carrying a given label (9 columns)
SELECT instance_id, status, created_at, completed_at, next_cursor
FROM df.list_instances(NULL, 100, 'nightly-report');
-- Keyset pagination: pass the previous page's next_cursor back in as after_cursor
SELECT * FROM df.list_instances(NULL, 50, NULL, '323032362d...');Pagination note:
next_cursoris computed overdf.instances(the authoritative, RLS-filtered set) independently of the per-row execution-metadata lookup, so it normally advances correctly. In a brief start-up window an instance can exist indf.instancesbefore its execution metadata is queryable; such a row is omitted from the current page. Edge case: if every row of a non-final page is omitted this way, that page returns zero rows and you cannot readnext_cursor(it is carried on each row) — retry shortly. A malformedafter_cursorraises an error; always pass anext_cursorvalue back verbatim.
Gets instance result (for completed instances).
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
instance_id |
TEXT | ❌ Literal | Target instance ID |
SELECT df.result('a1b2c3d4');Returns one row per node in an instance's graph, with each node's stored physical status alongside a read-time derived status. This is the primary tool for inspecting where an instance is and why a branch did or did not run.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
instance_id |
TEXT | ❌ Literal | Target instance ID |
Return columns:
| Column | Type | Description |
|---|---|---|
node_id |
TEXT | Node id (unique within the instance) |
node_type |
TEXT | SQL, THEN, IF, JOIN, RACE, LOOP, SLEEP, SIGNAL, HTTP, … |
query |
TEXT | SQL text for SQL nodes; a JSON config for compound/leaf nodes |
result_name |
TEXT | Capture name (|=>), or NULL |
left_node |
TEXT | First child node id, or NULL |
right_node |
TEXT | Second child node id, or NULL |
status |
TEXT | Physical stored status: pending, running, completed, failed |
result |
TEXT | JSON text payload for completed/failed nodes, else NULL |
status_details |
TEXT | JSON text metadata (see below), or NULL if never transitioned |
inferred_status |
TEXT | Derived status: physical status plus skipped, and loop re-entry surfaced as pending |
inferred_status_from_ancestor_id |
TEXT | Ancestor node id that drove a derived skipped/pending, or NULL |
updated_at |
TIMESTAMPTZ | Last physical status change |
result and status_details are returned as TEXT for compatibility. Cast to
jsonb when you need JSON operators (for example, status_details::jsonb->>'execution_id').
status_details JSON contract. Written by the worker through the
update-node-status activity and stored verbatim in df.nodes.status_details:
execution_id— the node's full execution stamp,{instance_path}::{generation}, e.g.a1b2c3d4::1::7f9a0012::2. The last::-token is the generation (continue-as-new count) of the orchestration that transitioned the node, and the preceding{instance_path}is that orchestration's instance id.instance_pathencodes sub-orchestration lineage: it starts with the root function instance id and appends a::{parent_generation}::{branch_or_loop_node_id}segment for each nestedJOIN/RACEbranch and each non-rootdf.loop()(which runs as its own child sub-orchestration). Public instance IDs and node IDs remain 8-char hex. Satellite execution stamps instead start with the engine rootpgdf-<databaseOID>-<installationUUID>-<localID>; that root contains no::, so existing child composition and path parsing are unchanged. Supersession is evaluated per scope: a node is superseded when a newer generation exists for its owninstance_path, or when any ancestor scope in its path has advanced to a newer generation. For a plain root-level loop this reduces to the second::-token being the loop generation.
inferred_status and inferred_status_from_ancestor_id are computed at read
time and are not stored in df.nodes.status_details.
Derived statuses. skipped is never written to df.nodes.status (it is not a
member of the nodes_status_chk constraint) — it exists only in inferred_status:
skipped— a non-terminal node whose nearest terminal ancestor already decided the branch will not run: the untaken arm of a completeddf.if(), the right side of a faileddf.then()/~>, or the abandoned (still-running) loser of a resolveddf.race(). A loser that already reachedcompleted/failedkeeps its physical status.pending(derived) — a node from an older loop generation that a newer ancestor generation has superseded; it will re-run, so it reads back aspendingrather than showing the previous iteration's terminal status.
df.explain() renders the same derived status for each node, so the two views
always agree.
SELECT node_id, node_type, status AS physical, inferred_status,
status_details::jsonb->>'execution_id' AS execution_id
FROM df.instance_nodes('a1b2c3d4')
ORDER BY node_id;Sets a workflow variable for the current user in this origin database (before df.start()). Each user has their own local variable namespace; variables are not shared between installations.
df.setvar is a setup helper, not a workflow node: do not use it inside df.seq, df.join, df.race, etc.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
name |
TEXT | ❌ Literal | Variable name |
value |
TEXT | ❌ Literal | Variable value |
SELECT df.setvar('api_url', 'https://api.example.com');Not for credentials. Values are stored as plaintext in
df.vars, anddf.start()copies every variable you own into durable execution history. Using{varname}keeps the value out ofdf.nodes.querybut not out of history. See Variables and secrets.
Gets a workflow variable owned by the current user.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
name |
TEXT | ❌ Literal | Variable name |
SELECT df.getvar('api_url');Removes a workflow variable owned by the current user.
df.unsetvar is a setup helper, not a workflow node.
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
name |
TEXT | ❌ Literal | Variable name |
SELECT df.unsetvar('api_url');Clears all workflow variables owned by the current user.
df.clearvars is a setup helper, not a workflow node.
SELECT df.clearvars();| Function | Parameters with Auto-Wrap |
|---|---|
df.seq(a, b) |
a, b |
df.as(fut, name) |
fut |
df.join(a, b) |
a, b |
df.join3(a, b, c) |
a, b, c |
df.race(a, b) |
a, b |
df.if(cond, then, else) |
cond, then, else |
df.loop(body, condition, continue_on_failure) |
body, condition |
df.start(fut, label) |
fut |
| All others | No auto-wrap (literals only) |
Rule of thumb: If a parameter expects a "node" (something that executes), it supports auto-wrap. If it expects a configuration value (name, URL, timeout), it's a literal.
Grants the privileges a role needs in this database's pg_durable installation.
By default, this grants general df usage but no HTTP or global metrics access.
include_http => true enables df.http() and df.http_multipart() in this origin.
with_grant => true delegates local administration and also grants df.metrics(),
exposing aggregate totals across every origin and user in the shared engine,
even when the grant is issued in a satellite.
Authorization is enforced by PostgreSQL’s native mechanisms: EXECUTE on this function is revoked from PUBLIC (so only roles explicitly granted access can call it), and the inner GRANT statements run as the caller via SECURITY INVOKER, so the caller must hold the underlying privileges WITH GRANT OPTION.
| Parameter | Type | Description |
|---|---|---|
role_name |
TEXT | The role to grant privileges to |
include_http |
BOOLEAN | Optional, defaults to false; grants local EXECUTE on df.http() and df.http_multipart() |
with_grant |
BOOLEAN | Optional, defaults to false; grants local privileges WITH GRANT OPTION, grant/revoke helper access, and shared-engine df.metrics() access |
SELECT df.grant_usage('app_role');
SELECT df.grant_usage('app_role', include_http => true);
SELECT df.grant_usage('admin_role', with_grant => true);Revokes all privileges previously granted by df.grant_usage(), including any df.http() access. Authorization is enforced the same way as df.grant_usage() — EXECUTE is revoked from PUBLIC, and the inner REVOKE statements run as the caller. On upgraded installs, revoking df.http() from PUBLIC is still a separate manual step.
| Parameter | Type | Description |
|---|---|---|
role_name |
TEXT | The role to revoke privileges from |
SELECT df.revoke_usage('app_role');Returns shared-engine totals: total_instances, running_instances,
completed_instances, failed_instances, total_executions, and total_events.
These are not filtered by local RLS or origin database. PUBLIC EXECUTE is revoked;
access requires a direct administrator grant or
df.grant_usage(..., with_grant => true). Ordinary usage grants omit this function.
These settings are configured via ALTER SYSTEM SET or postgresql.conf. See each setting for reload or restart requirements.
Control database for the single runtime/provider store (default postgres,
Postmaster context, restart required). Explicitly install pg_durable here before
satellites. Satellite installs use SQLx with the worker credential to verify control
readiness version 2 or later; extension version strings need not be identical.
This setting is not the default SQL target for satellite starts; their origin is.
Connection role for worker management, origin routing, and provider operations
(default postgres, a superuser; Postmaster context, restart required). A custom
role needs CONNECT and required df metadata/guard rights in each origin, plus
access for origin-local HTTP privilege lookup. BYPASSRLS grants no database,
schema, table, or function privileges by itself. SQL nodes still authenticate as
the captured submitting role in the execution database.
Shared budget for satellite metadata connections across activities and maintenance.
| Property | Value |
|---|---|
| Type | integer |
| Default | 12 |
| Range | 2 to 1000 |
| Context | POSTMASTER (restart required) |
Each active route reserves two slots: a guard transaction locking
df._installation, df.instances, and df.nodes in ACCESS SHARE mode, and a
metadata connection. Routes close after use; there is no idle pool per database
or database-name count ceiling. Maintenance shares the same budget. The control
pool's pg_durable.max_management_connections limit is unchanged and separate.
Origin admission waits up to 30 seconds. Metadata connections use a 1.5-second
lock timeout and a 5-second statement timeout; these are not user SQL timeouts.
Guard transactions disable server idle-in-transaction and transaction timeouts
so long-running activities retain their installation locks.
See Connection Limits for total budgeting.
Maximum concurrent transaction_mode => 'new' loopback launches per database,
enforced through advisory locks before connecting. Default 2, range 1 to
1000, Postmaster context (restart required). Launches connect to the caller's
database, not the explicit SQL target. Excess callers wait up to
pg_durable.new_transaction_start_timeout seconds (default 5).
Controls whether pg_durable allows durable function instances whose submitted_by role is a PostgreSQL superuser.
| Property | Value |
|---|---|
| Type | boolean |
| Default | off |
| Context | SUSET (superuser can change at runtime; no restart needed) |
| Visibility | Hidden from SHOW ALL and pg_settings for non-superusers |
When off (default):
df.start()raises an error immediately ifcurrent_useris a superuser.- The background worker rejects any instance whose
submitted_byresolves to a superuser at execution time, even if the row was tampered with after submission.
When on:
- Superusers may submit durable functions. Their SQL nodes execute with superuser privileges.
- Intended for administrative tasks in single-tenant or fully-trusted deployments.
-- Enable (requires superuser)
ALTER SYSTEM SET pg_durable.enable_superuser_instances = on;
SELECT pg_reload_conf();
-- Disable (default; recommended for multi-tenant)
ALTER SYSTEM SET pg_durable.enable_superuser_instances = off;
SELECT pg_reload_conf();
-- Check current value (superuser only)
SHOW pg_durable.enable_superuser_instances;Security note: Setting this GUC to on in a multi-tenant environment allows any role with BYPASSRLS to forge submitted_by to a superuser OID and execute arbitrary SQL as superuser. Keep off unless you have a specific need and understand the risk. See docs/superuser_guc.md for the full threat analysis.
Maximum number of rows df.list_instances() returns in a single call. A request for more rows than this raises an error instead of silently truncating the result, so external clients paginate explicitly (via after_cursor/next_cursor) rather than relying on a silent cap.
| Property | Value |
|---|---|
| Type | integer |
| Default | 1000 |
| Range | 1 – 1000000 |
| Context | SUSET (superuser can change at runtime; no restart needed) |
Both df.list_instances() overloads (basic and paginated) enforce this cap. By default an ordinary (non-superuser) caller cannot raise it, so the guardrail holds from a user session — a superuser may delegate that ability with GRANT SET ON PARAMETER pg_durable.list_instances_max_limit TO <role>, but without that grant it stays superuser-settable only.
Sizing note:
df.list_instances()materializes up tolimit_countrows per call, so raise the cap only as high as a single response should reasonably hold. For very large exports, prefer paging withafter_cursor/next_cursorover one huge page rather than setting the cap near its maximum.
-- Inspect the current cap
SHOW pg_durable.list_instances_max_limit;
-- Raise it for an admin reporting workload (requires superuser)
ALTER SYSTEM SET pg_durable.list_instances_max_limit = 5000;
SELECT pg_reload_conf();Behavior change (v0.2.4): prior to v0.2.4,
df.list_instances()silently truncatedlimit_countto 10000. It now raises an error whenlimit_countexceeds this GUC (default 1000). Callers that previously requested very large pages should lowerlimit_countor use the paginated overload (after_cursor/next_cursor).
Controls whether the background worker writes the SQL text of an executed workflow node to the PostgreSQL server log.
| Property | Value |
|---|---|
| Type | boolean |
| Default | on |
| Context | POSTMASTER (set in postgresql.conf; requires restart) |
The SQL is logged after variable substitution, so credentials substituted into a query reach the server log in cleartext. SQL cannot be reliably redacted after substitution, so this is an on/off switch.
# postgresql.conf
pg_durable.log_workflow_sql = offTurning it off keeps the submitting role and any explicit target database in the worker trace but drops the statement text. It does not suppress workflow results or error messages, which can also contain sensitive data.
PostgreSQL's own statement logging is independent: settings such as log_statement and log_min_duration_statement can still log executed SQL when this GUC is off. Keep credentials out of SQL even with this setting disabled. See What reaches the server log and Variables and secrets.