- Documentation
PostgreSQL integration
Start workflows from new or changed rows and read and write a client's PostgreSQL database from inside a workflow.
What it does
The PostgreSQL integration lets your agency react to and change a client's PostgreSQL database from a workflow. Connect the database once with a connection string or discrete host, port, user, password, and database. Triggers start a workflow when a row is added or changed, when your own query returns a new row, or when the schema gains a table or column. Actions find rows without writing SQL, get one row by its primary key, run a parameterized query, insert a row, update or delete rows by an equality filter, and upsert a row on a conflict key. Connection details are encrypted at rest, every value you pass is bound as a parameter rather than spliced into the SQL, and results are capped so a large read cannot overwhelm a run.
Connect a PostgreSQL database
- Open your workspace in TaskJuice and add a PostgreSQL connection on the node that uses it.
- Paste a connection string, for example
postgresql://user:password@db.example.com:5432/appdb?sslmode=verify-full. The TLS posture is read from the connection string;sslmode=verify-fullvalidates the server certificate and hostname. - Or fill in the host, port (5432 by default), user, password, and database separately. Discrete connections default to verifying the full certificate chain.
- Save the connection. The password and connection string are stored encrypted and are never shown again or written to run output.
Provision a least-privilege database user scoped to the tables the workflow touches, and expose the database on a public host the workflow can reach over TLS. Private and internal addresses are blocked. A user that only needs triggers and reads can be granted SELECT alone.
Network access. TaskJuice reaches the database from more than one network. Workflow steps in a published run connect from a fixed set of outbound addresses. Triggers, which check the database on an interval, and the editor's Test step connect from cloud infrastructure that has no fixed IP addresses. If the database only accepts listed addresses, triggers and editor tests time out, so a step that fails its editor test with a connection error can still work in a published run. Unless you have a reason to restrict it, allow access from anywhere (0.0.0.0/0) and let a long, random password and TLS protect the database.
Triggers
PostgreSQL has no way to call out to a workflow when data changes, so each trigger checks the database on an interval you choose (every 5 minutes by default, as often as every minute). Each check runs one small query. Rows, tables, and columns that already exist when you turn a trigger on never fire; only changes after that point do. Every check that finds something starts one run whose items array holds everything found, so add a Loop node to handle the items one at a time.
postgresql/new-rowfires when a row is added to a table. Point it at a column whose value only grows for new rows, usually aserialor identity primary key.postgresql/new-or-updated-rowfires when a row is added or changed. Point it at atimestamportimestamptzcolumn your schema sets on every write, for example anupdated_at timestamptzcolumn that aBEFORE UPDATEtrigger sets tonow(), with a unique ID column as the tie-breaker.postgresql/new-row-custom-queryfires when your ownSELECTreturns a row it has not returned before. Name a unique column so a row fires once even if its other values change later.postgresql/new-tablefires when a table or view is created in the connection's current schema (usuallypublic).postgresql/new-columnfires when a column is added to a table in that schema. Name a table to watch just that one, or leave it empty to watch them all.
Actions
postgresql/findfinds rows in a table without writing SQL. Filter with conditions such asstatus = openortotal >= 100(operators=,!=,>,>=,<,<=,like,not-like,in,not-in,is-null,is-not-null), match all or any of them, pick the columns to return, sort, and cap the row count.postgresql/get-rowgets one row by its primary key, for example{ "id": 42 }; for a composite key, give every key column. It returnsfoundand therow, orfound: falseandrow: nullwhen no row has that key. A key that matches more than one row fails the step rather than picking one.postgresql/queryruns a SQL statement with$1, $2, …placeholders and a matchingparamsarray, returning the result rows and the row count.postgresql/insertinserts a row from a column-to-value map and returns the inserted row (with its generated id and defaults) and the row count.postgresql/updateupdates the rows matching an equality filter, setting new column values, and returns the affected row count.postgresql/upsertinserts a row or, on a conflict with the columns you name, updates it (INSERT … ON CONFLICT DO UPDATE). It returns the inserted or updated row, including when the row already existed and nothing changed.postgresql/deletedeletes the rows matching an equality filter and returns the deleted row count.
Insert and Upsert return the whole written row, every column as stored, so a later step can use its id or any default the database filled in, for example {{ $steps.insert_1.data.rows[0].id }}. The rows come back empty when the database user has no SELECT on the table; the write itself still happens. When every value you upsert is one of the conflict columns, Postgres reads an existing row back through a no-op update, so the table's row-level UPDATE triggers run on that row, and its statement-level UPDATE triggers run on every such upsert. If that update is refused (the user has no UPDATE on the table, or an UPDATE trigger or a constraint rejects it), the upsert still runs as an insert-if-missing, and the rows come back empty, for a new key too.
Write a safe query
Always pass values through params, never by building the SQL string yourself:
sql: SELECT id, email FROM customers WHERE status = $1 AND created_at > $2
params: ["active", "2026-01-01"]Table and column names in the structured actions and triggers are validated against a strict allowlist and quoted for you, so a name containing a quote, semicolon, or whitespace is rejected before the statement runs.
Known limitations
- Triggers check on an interval rather than reacting instantly. Keep the watched columns indexed;
new-or-updated-rowreads best with a composite index on the timestamp and ID columns. new-or-updated-rowtracks its position to the millisecond and reads rows that share a millisecond in ID order, so every row is delivered exactly once even when one bulkUPDATEstamps thousands of rows with the samenow(). A backlog spread over time is read straight from the composite index, a few milliseconds per check however large the backlog. Rows that share a single millisecond are scanned together on every check, and keep being scanned after they have all been delivered, until a newer row moves the trigger's position past that millisecond: measured at about a third of a second per million such rows. Adatecolumn works as the timestamp column, at one-day resolution.- The ID column of
new-rowand the timestamp and ID columns ofnew-or-updated-rowmust never beNULL. A row with aNULLin one of them is never delivered, and it can stop the trigger with a configuration error. That happens when any row has aNULLtimestamp at the moment you turnnew-or-updated-rowon, when the row with the newest timestamp has aNULLID, when any row has aNULLID at the moment you turnnew-rowon, or whennew-rowis turned on over an empty table and a row with aNULLID then arrives. ANULLID on an older row ofnew-or-updated-rowdoes not stop the trigger; that row is simply never delivered. Declare the columnsNOT NULL. - Timestamps without a time zone (
timestamp,date) are handled as plain wall-clock values: the trigger and the actions read and compare them exactly as stored, with no time-zone conversion, so values in a daylight-saving changeover hour behave like any other. - The custom-query trigger re-runs your whole query on every check. Keep it selective with a
WHEREand aLIMITof a few hundred rows. - Find and query return at most 1,000 rows and around 5 MB per call; when a read is clipped the result sets
truncatedtotrueso a workflow can detect it. Add aLIMITand aWHEREclause to keep reads bounded. - A statement that runs longer than 30 seconds is cancelled by the database. Keep queries indexed and selective.
- Connections are short-lived: each action and each trigger check opens and closes its own connection. Point the connection at a pooler (such as PgBouncer) if your database has a low connection limit.