SQL Query
Runs a statement against one of your SQL connections. It is the node most pipelines end at, and its writes are the ones store-and-forward protects.
Type: database-sql · Category: Datasource · Ports: one input · one output


When to use it
- Writing production, downtime or quality records to a reporting database
- Reading context a pipeline needs — the running work order, a product spec, a threshold
- Updating rows a later step has enriched
For high-rate time series use QuestDB, which is built for the volume. For tag history, enable history on the tag and let the historian do it — do not write your own.
Settings
| Setting | Key | Purpose |
|---|---|---|
| Connection | connectionId | Which SQL connection. |
| Query | query | The statement, with {{…}} filled from the incoming message. |
Getting values into the statement
{{ }} is not string pastingEach {{…}} is replaced by a bound placeholder and its value is handed to the driver separately,
so the statement is parameterised and a product code containing an apostrophe cannot corrupt it.
This is the safe way to get a value into a query — do not build SQL by concatenating strings in a
Python node instead.
This is not the :name syntax used by named queries.
Named queries are a Designer feature for screen bindings; this node has no named-query control and
runs the statement you type.
What you can reference
| Path | Resolves to |
|---|---|
{{payload}} | The whole payload |
{{payload.units}} | A field, at any depth — {{payload.order.line}} works |
{{topic}} | The message topic |
{{timestamp}} · {{id}} | The message timestamp and id |
{{vars.shift}} | Message metadata, also available as {{metadata.shift}} |
{{origin.id}} · {{origin.type}} | The node that produced the message |
With no upstream message the query runs exactly as typed, with nothing substituted — which is what
makes a plain SELECT work behind an Inject.
{{payload.data}} against a message with no data field is not blanked and not bound —
the token stays in the statement exactly as you typed it and is handed to the database as raw SQL:
-- you wrote
WHERE units = {{payload.units}}
WHERE data = {{payload.data}}
-- the database receives
WHERE units = ? -- bound, value 42
WHERE data = {{payload.data}} -- raw text, no argument
Which is a syntax error, and the error the database returns names the {{, not the missing field.
The rule is deliberate — a silent NULL would make a WHERE match the wrong rows and a
DELETE match too many — but it means a typo in a path fails at the database, not in the
editor. Check the path against a real message in the Debug node before you rely on it.
Note this is the opposite of what the notification and AI nodes do with the same syntax, where an unresolved token becomes empty.
Example A — reading
Fetch the work order currently running on a line, to enrich everything downstream.
SELECT wo.id AS "workOrder",
wo.product_code AS "productCode",
wo.target_rate AS "targetRate",
wo.started_at AS "startedAt"
FROM work_order wo
WHERE wo.line = {{payload.line}}
AND wo.status = 'RUNNING'
ORDER BY wo.started_at DESC
LIMIT 1
In
{ "payload": { "line": "LINE1" } }
Out — rows come back as an array, even when there is one:
{
"payload": [
{ "workOrder": "WO-10442", "productCode": "SKU-889", "targetRate": 3000, "startedAt": "2026-08-12T06:00:00Z" }
]
}
A query that matches nothing returns [], not null. Guard for it:
def transform(payload, message):
if not payload:
return None # no running order — end this branch quietly
return payload[0]
Example B — writing
INSERT INTO production_log (ts, line, work_order, units, oee)
VALUES ({{payload.ts}}, {{payload.line}}, {{payload.workOrder}}, {{payload.units}}, {{payload.oee}})
In
{
"payload": {
"ts": "2026-08-12T07:00:00Z",
"line": "LINE1",
"workOrder": "WO-10442",
"units": 2841,
"oee": 0.847
}
}
Out — a write reports what it affected rather than returning rows:
{ "payload": { "rowsAffected": 1 } }
Example C — an upsert, so a replay cannot duplicate
Writes here are covered by store-and-forward: if the database
is unreachable the write is buffered and replayed in order rather than lost. That is also why an
INSERT should be idempotent — a replay after a timeout may repeat a statement whose first attempt
actually committed.
INSERT INTO production_log (ts, line, work_order, units, oee)
VALUES ({{payload.ts}}, {{payload.line}}, {{payload.workOrder}}, {{payload.units}}, {{payload.oee}})
ON CONFLICT (ts, line) DO UPDATE
SET units = EXCLUDED.units,
oee = EXCLUDED.oee
Gotchas
- A placeholder that names a key the message does not have resolves to nothing. Rename a field in an upstream Python node and the insert silently changes shape.
- This node opens no transaction. For atomicity across several statements, use
system.db.transaction()in a scripting node.