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''')Repeats body (forever or while condition is true).
| Parameter | Type | Auto-wrap | Description |
|---|---|---|---|
body |
TEXT | ✅ Auto-wrap | Node to repeat |
condition |
TEXT | ✅ Auto-wrap | (Optional) Continue while truthy |
-- Infinite loop
df.loop('SELECT process_item()' ~> df.sleep(1))
@> ('SELECT process_item()' ~> df.sleep(1)) -- operator (infinite only)
-- While loop (function only, no operator)
df.loop('SELECT process_item()', 'SELECT count(*) > 0 FROM queue')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)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 |
df.start('SELECT 1') -- auto-wrapped
df.start(df.sleep(10) ~> 'SELECT 2') -- explicit nodes
df.start('SELECT 1', 'my-job') -- with labelSends 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 |
JSONB | Result/error payload for completed/failed nodes, else NULL |
status_details |
JSONB | Worker-written node 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 |
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 segmented execution path, e.g.a1b2c3d4::1::7f9a0012::1. Parse it positionally: the second::-token is the root loop generation (used to detect superseded loop iterations), and the trailing segments encodeJOIN/RACEsub-orchestration lineage.
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->>'execution_id' AS execution_id
FROM df.instance_nodes('a1b2c3d4')
ORDER BY node_id;Sets a workflow variable for the current user (before df.start()). Each user has their own variable namespace — variables set by one user are invisible to others.
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');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, cond) |
body, cond |
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 to use pg_durable. By default this grants general df usage but does not grant EXECUTE on df.http(). Pass include_http => true to opt a role into HTTP access. Pass with_grant => true to allow the role to delegate access to others.
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; when true, also grants EXECUTE on df.http(text, text, text, jsonb, integer) |
with_grant |
BOOLEAN | Optional, defaults to false; when true, grants all privileges WITH GRANT OPTION and retains EXECUTE on df.grant_usage / df.revoke_usage |
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');These settings are configured via ALTER SYSTEM SET or postgresql.conf and take effect after SELECT pg_reload_conf() (no restart required).
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).