Skip to main content

PostgreSQL

Read and write your own PostgreSQL database from a workflow: Azure Database for PostgreSQL, Amazon RDS, Supabase, Neon, or a server of your own.

Each Environment has its own connection, so Testing and Production can point at different databases. The data stays in your database; the platform only connects with the settings you give it.

The Module adds six Tasks. Two take SQL you write (Query, Execute SQL); four need no SQL at all (Find records, Insert records, Update records, Delete records).

What you can build​

  • A form that fills a table: a visitor submits a form, Insert records stores {form.fields} as a row; a second submission with the same email updates the row instead of failing.
  • A look-up in a conversation: Find records by phone or email, then answer with {customer.first.name} and {customer.first.plan}.
  • A nightly report: a scheduled flow runs a Query, and its {result.rows} feed Send bulk email or Query file.
  • Housekeeping: Delete records whose expires_at is before {execution.now}.
  • An order pipeline: Execute SQL with INSERT … RETURNING id keeps the new id in {result.value} for the next steps.

Configuration​

Open the Environment's settings and fill in the PostgreSQL card.

SettingDescription
Host, PortThe server, e.g. myserver.postgres.database.azure.com, port 5432 (6543 for a Supabase pooler). Only public servers can be reached: a server on a private or local address is refused.
DatabaseThe database to connect to.
SchemaWhere unqualified table names are looked up (search_path). Empty: the server's default, usually public. A task can still name schema.table.
Username, PasswordThe database user. Give it only the rights the flows need: a read-only user for an Environment that only queries. The password is stored encrypted.
EncryptionTLS, verified certificate (default): the server's certificate is checked against its host name, what Azure, RDS and any public CA give you. TLS, any certificate: for a self-signed server. TLS when available and Clear text only on a network you trust.
Read onlyOn: only Query and Find records run; every other Task fails before touching the database, and the connection itself is opened read only. For an Environment that must never change data.
Statement timeoutAdvanced. A statement running longer than this is cancelled by the server (default 30 s, capped by the platform).
Max rowsAdvanced. Most rows a Task puts in a variable (default 1,000, capped by the platform). A longer result is cut and {result.truncated} is true.

When a flow with PostgreSQL steps is deployed, the Environment must have a connection (the deploy is refused otherwise) and the database is contacted once; a server that does not answer is reported in the deployment log, and the flow's PostgreSQL steps fail until it does.

How it works​

Variables are always safe​

{variables} in SQL, in conditions and in values are passed to the database as parameters, never pasted into the statement. Whatever a visitor types into a form can only be compared or stored, never executed. The database infers each parameter's type from where it is used: WHERE id = {id} compares as the column's type, total > {min} as a number. When it cannot (a bare SELECT {x}, a function with several forms), add a cast: {when}::date, {amount}::numeric.

  • A variable no step has written is NULL.
  • A record or a list (a form's fields, a query's rows) is passed as JSON text: write {meta}::jsonb to store it in a json / jsonb column.
  • Dates are passed as ISO text (2026-10-27 10:00:00+00 for UTC times), numbers with a dot (12.5), true/false as true / false.
  • {execution.now} and {execution.today} work everywhere a variable does.

Names are exact​

Table and column names of the form Tasks are quoted exactly as you write them, so they must match what the database stores, case included; most names are lowercase (customers, created_at). A name with other characters than letters, digits and underscores is refused. In your own SQL, write names as you would in any client.

Rows in variables​

Every Task stores the same result object:

FieldDescription
{result.rows}The rows, a list of records keyed by column name: {result.rows.0.email}.
{result.first}The first row: {result.first.email}. Empty when there is none.
{result.value}The first column of the first row: a count, a sum, the id of RETURNING id.
{result.count}How many rows are in rows.
{result.affected}How many rows the statement inserted, changed or deleted (0 for a query).
{result.columns}The column names, comma separated.
{result.truncated}True when more rows matched than the limit kept.

Values come back as the flow uses them: numbers stay numbers, true / false stay booleans, dates stay dates, json / jsonb columns become objects ({result.first.meta.tier}, {result.first.meta.tags.0}), uuid and bytea become text (base64 for bytea).

One statement per step, one transaction per step​

Each Task runs one statement in its own transaction. A Query runs in a read-only transaction: a statement that writes fails with read_only, whatever the user's rights. A script of several statements must be split into steps (or wrapped in a function).

Tasks​

Query​

Runs a SELECT and keeps its rows.

PropertyDescription
SQLOne SELECT with {variables} where values go.
Max rowsMost rows to keep. 0: the Environment's Max rows.
Store result in{result}, see Rows in variables.
SELECT id, email, total
FROM customers
WHERE city = {form.fields.city} AND total > {min}::numeric
ORDER BY total DESC

Execute SQL​

Runs one statement that changes data (INSERT, UPDATE, DELETE) or the schema. A RETURNING clause fills {result.rows}, {result.first} and {result.value}; {result.affected} counts the rows changed.

INSERT INTO orders (customer_id, total, details)
VALUES ({customer.id}, {order.total}, {order}::jsonb)
RETURNING id

Find records​

Reads the rows of a table that match your conditions, without SQL.

PropertyDescription
Tablecustomers, or sales.orders for another schema.
ColumnsThe columns to read, comma separated. Empty: every column.
Rows to readConditions (a list) or an SQL condition (status = 'open' AND total > {min}).
Keep rows that match / Ignore caseAll or any of the conditions; text conditions matching upper and lower case alike.
WhereConditions: a column, an operator (equals, contains, starts with, matches regex, is empty, greater than, is one of (; separated)…) and a value. An empty value matches no row, so a blank form field can never turn "equals" into "every row".
Sort, LimitSort keys and the most rows to keep (0: the Environment's Max rows).

Insert records​

Adds rows to a table.

PropertyDescription
TableThe table.
Rows fromA variable: one {variable} holding a record ({form.fields}) or a list of records ({result.rows} of a query). Each key is a column; a key a record lacks takes the column's default. Values I list: the columns of one row, each with a value.
If the row existsWhat to do when a unique key already holds the row's value: Fail (error unique_violation), Skip it, or Update it (every other column of the row is overwritten). Skip and Update need the Key columns of the unique key and are atomic (INSERT … ON CONFLICT).
Return the rowsOn: the rows as stored (defaults and generated ids included) go to {result.rows}.

An empty value stores NULL; a nested value (a record inside the record) is stored as JSON. A list of records is inserted in batches of 500 rows per statement.

Update records​

Changes the rows that match.

PropertyDescription
Table, Rows to change, WhereAs in Find records.
Every rowOff (default): a step without a condition fails, so a missing condition can never change the whole table. On: no condition means every row.
SetThe changes: a column and a value ({form.fields.status}; empty stores NULL) or an SQL expression ("count" + 1, now(), lower("email")).
Return the rowsOn: the changed rows go to {result.rows}.

Delete records​

Removes the rows that match. Same conditions and the same Every row guard as Update records; {result.affected} is the number of rows deleted.

Errors​

When a Task fails it takes its error flow and writes {postgresError}:

FieldDescription
codeOne of the codes below: route a Switch on it.
messageA short sentence for the end user.
detailWhat went wrong exactly, with the server's words (Key (email)=(ann@example.com) already exists.).
sqlStateThe server's SQLSTATE (23505, 42P01…), empty when the server was not reached.
constraint, columnThe constraint or column the server named, when it named one.
CodeWhen
invalid_inputThe step is not set up correctly: an empty SQL, a bad table name, no condition without Every row, a host on a private network.
not_configuredThe Environment has no PostgreSQL connection, or it is incomplete.
invalid_sqlThe server refused the statement: syntax, an unknown table or column, a type it could not infer.
invalid_dataA value does not fit its column (abc into an integer, bad JSON into jsonb).
unique_violationA unique key already holds the value. constraint names it.
foreign_key_violationThe row refers to something that does not exist, or is still referred to.
not_null_violationA required column has no value. column names it.
check_violation, constraint_violationA CHECK rule or another constraint refused the row.
read_onlyThe statement writes on a read-only connection, or in a Query.
auth, permissionThe sign-in was refused; the user may not do this on the table.
timeoutThe statement ran longer than the Environment's timeout.
too_largeThe rows do not fit in a variable: select fewer columns or rows.
unavailableThe server cannot be reached, is restarting, or is out of connections.
failedAnything else the server reported (a RAISE in a trigger, say).

Limits​

Set by the platform (the engine's Postgres section): at most 50,000 rows and 32 MB per result, a statement timeout of at most 600 s, and a small connection pool per Environment on each engine instance. Private, loopback and link-local servers are refused outside development.