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_atis before{execution.now}. - An order pipeline: Execute SQL with
INSERT … RETURNING idkeeps the new id in{result.value}for the next steps.
Configuration
Open the Environment's settings and fill in the PostgreSQL card.
| Setting | Description |
|---|---|
| Host, Port | The 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. |
| Database | The database to connect to. |
| Schema | Where unqualified table names are looked up (search_path). Empty: the server's default, usually public. A task can still name schema.table. |
| Username, Password | The 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. |
| Encryption | TLS, 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 only | On: 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 timeout | Advanced. A statement running longer than this is cancelled by the server (default 30 s, capped by the platform). |
| Max rows | Advanced. 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}::jsonbto store it in ajson/jsonbcolumn. - Dates are passed as ISO text (
2026-10-27 10:00:00+00for UTC times), numbers with a dot (12.5), true/false astrue/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:
| Field | Description |
|---|---|
{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.
| Property | Description |
|---|---|
| SQL | One SELECT with {variables} where values go. |
| Max rows | Most 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.
| Property | Description |
|---|---|
| Table | customers, or sales.orders for another schema. |
| Columns | The columns to read, comma separated. Empty: every column. |
| Rows to read | Conditions (a list) or an SQL condition (status = 'open' AND total > {min}). |
| Keep rows that match / Ignore case | All or any of the conditions; text conditions matching upper and lower case alike. |
| Where | Conditions: 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, Limit | Sort keys and the most rows to keep (0: the Environment's Max rows). |
Insert records
Adds rows to a table.
| Property | Description |
|---|---|
| Table | The table. |
| Rows from | A 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 exists | What 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 rows | On: 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.
| Property | Description |
|---|---|
| Table, Rows to change, Where | As in Find records. |
| Every row | Off (default): a step without a condition fails, so a missing condition can never change the whole table. On: no condition means every row. |
| Set | The changes: a column and a value ({form.fields.status}; empty stores NULL) or an SQL expression ("count" + 1, now(), lower("email")). |
| Return the rows | On: 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}:
| Field | Description |
|---|---|
code | One of the codes below: route a Switch on it. |
message | A short sentence for the end user. |
detail | What went wrong exactly, with the server's words (Key (email)=(ann@example.com) already exists.). |
sqlState | The server's SQLSTATE (23505, 42P01…), empty when the server was not reached. |
constraint, column | The constraint or column the server named, when it named one. |
| Code | When |
|---|---|
invalid_input | The 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_configured | The Environment has no PostgreSQL connection, or it is incomplete. |
invalid_sql | The server refused the statement: syntax, an unknown table or column, a type it could not infer. |
invalid_data | A value does not fit its column (abc into an integer, bad JSON into jsonb). |
unique_violation | A unique key already holds the value. constraint names it. |
foreign_key_violation | The row refers to something that does not exist, or is still referred to. |
not_null_violation | A required column has no value. column names it. |
check_violation, constraint_violation | A CHECK rule or another constraint refused the row. |
read_only | The statement writes on a read-only connection, or in a Query. |
auth, permission | The sign-in was refused; the user may not do this on the table. |
timeout | The statement ran longer than the Environment's timeout. |
too_large | The rows do not fit in a variable: select fewer columns or rows. |
unavailable | The server cannot be reached, is restarting, or is out of connections. |
failed | Anything 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.