The Data Table node
Every resource, operation and parameter the Data Table node offers, and what it outputs.
The Data Table node runs one operation against a project's
Data Table as a step of a workflow. You find it under
Utilities in the canvas palette, and its card reads
<resource> · <operation> · <table> so a graph says what it does without being opened.
Two controls decide everything else on the panel:
| Control | Values |
|---|---|
| Resource | Table (act on tables themselves) or Row (act on rows in a table) |
| Operation | One of the operations the chosen resource offers |
Only the chosen operation's own fields are shown. Switching Resource moves Operation to the first operation of the new resource, because the pair has to stay runnable.
A workflow run has no role and no permissions. What a node may do is decided by the node's own
configuration — which is why a table delete has a flag of its own below.
Operations at a glance
Resource: Table
| Operation | What it does | Parameters |
|---|---|---|
| Create table | Creates a table in the workflow's project | Table name, columns (name + type, ordered), Return existing if it exists |
| List tables | Lists the project's tables with their row counts | Order by (name, createdAt, updatedAt), Limit |
| Rename table | Renames the selected table | Table, New name |
| Delete table | Drops the table and every row in it | Table (or Table name), Dry Run, Allow deleting tables |
| Add column | Appends a column; existing rows are untouched | Table, Column name, Column type |
| Remove column | Removes a column and strips its key from every row | Table, Column name |
Resource: Row
| Operation | What it does | Parameters |
|---|---|---|
| Insert row(s) | Writes new rows | Table, Mapping (Manual / Automatic), Optimize Bulk |
| Get row(s) | Reads matching rows | Table, Conditions, Read as (rows / file), Order by, Limit, Return all matching rows, Cursor |
| Update row(s) | Patches the named columns of matching rows | Table, Mapping, Conditions, Dry Run, Apply to all rows |
| Delete row(s) | Deletes matching rows | Table, Conditions, Dry Run, Apply to all rows |
| Upsert row | Updates the rows the key matches, or inserts one | Table, Mapping, Conditions (the key), Dry Run |
| Upsert rows in bulk (by key) | Upserts thousands of rows in one run, matched on key columns | Table, Match rows on (key columns), Rows from (array or file), Dry Run |
| Row exists | Reports whether anything matches, and how many | Table, Conditions |
delete is the one name both resources carry, and it means a different thing under each — drop the
table, or delete matching rows. That is why Resource is a field of its own.
Choosing the table
Every operation except Create table and List tables acts on a table you pick from the project's own tables. The picker lists the tables of the project the workflow belongs to, and Create new table… creates one without leaving the canvas.
Two operations take a literal name instead of (or as well as) the picked table:
- Create table writes the name you type.
- Delete table falls back to the name when no table is picked — it is the one operation whose contract addresses a table by name, so a workflow can drop a table it did not create.
A table name and a column name are never expressions. A register whose table name changed from run to run would not be a register; it would be a workflow that creates a new table every time it fires.
Writing cells: Manual and Automatic mapping
Insert, Update and Upsert write cells, and there are two ways to say which:
One row per column, each with its own value. Picking a table seeds the list with the table's whole column list, so there is nothing to add before you can start typing.
An insert under Manual mapping writes one row per run: each mapping row resolves to one cell,
and a cell is a scalar or null. An array that lands in a cell is refused, naming the column.
{
"resource": "row",
"operation": "insert",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Contract register",
"mapping": "manual",
"mappingRows": [
{
"id": "map-1",
"column": "Contract",
"value": "{{ upstream.extract-1.output.json.title }}",
"valueMode": "expression"
},
{
"id": "map-2",
"column": "Deadline",
"value": "{{ upstream.extract-1.output.json.deadline }}",
"valueMode": "expression"
},
{ "id": "map-3", "column": "Status", "value": "open", "valueMode": "fixed" }
]
}One expression for the whole row. Its field names are matched against the table's columns, and the
names that match nothing come back in the node's output as ignoredFields rather than failing the
run.
An array source inserts one row per item — the array is the row set, and if one item is bad, none of them are written.
{
"resource": "row",
"operation": "insert",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Contract register",
"mapping": "automatic",
"automaticSource": {
"id": "src",
"value": "{{ upstream.extract-1.output.json.contracts }}",
"valueMode": "expression"
}
}An Update or an Upsert patches only the columns its mapping names. Columns you leave out keep the value they already have, so two workflows can own different columns of the same row without overwriting each other.
Bulk upsert by key
Upsert rows in bulk (by key) writes a whole row set in one run — a daily refresh of 15,000 open invoices is one node, not a Loop of 15,000. Each row is matched on the key columns you tick under Match rows on:
- a row whose key is not in the table is inserted;
- a row whose key is there updates every row holding that key, like Upsert row does;
- a row whose matched rows already hold the same values is left alone — not rewritten, and its
updatedAtdoes not move.
Keys are compared by their typed value: "9" and 9 are one key on a Number column, and two
ways of writing the same instant are one key on a Date column.
The rows come from Rows from:
- Array expression — an expression resolving to an array of objects, as for an automatic insert. An array travels inside the run itself, which refuses anything over about 2 MB, so this is for a few thousand rows at most.
- File — an expression resolving to a file holding a JSON array of objects or JSONL (one object per line): a Code node's output file, an FTP download, or a Get read as a file. Only the file's id travels between steps; the rows are read on the server, so the size of the run's own data does not grow with the file.
{
"resource": "row",
"operation": "bulkUpsert",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Open invoices",
"keyColumns": ["Invoice"],
"rowsSourceMode": "file",
"rowsFileExpression": "{{ upstream.export-1.output.file }}",
"dryRun": false
}The rows' field names are matched against the table's columns like Automatic mapping: a field
the table has no column for is ignored and listed in result.ignoredFields.
It is all or nothing. Every row is checked before anything is written, and the node fails — writing nothing — when a row holds a value its column cannot take (the message names the row), when a row has no value in a key column (it would be inserted again on every run), or when two rows have the same key (the message names both rows and the key). The write itself is one transaction.
Bulk upserts on one table run one at a time, and Upsert row nodes on that table wait while one runs, so no key is ever written twice. Dry Run returns the same counts and writes nothing, and it is refused by everything that would refuse the real write.
Fixed vs Expression
Every value field — a mapped cell, a condition's comparison value, the automatic source — carries the same Fixed / Expression toggle that Edit Fields uses:
- Expression — resolved against the run before the operation happens, over the same four
namespaces as every other node:
{{ trigger.<path> }},{{ upstream.<nodeId>.output.… }},{{ variables.<name> }}and{{ loop.item }}/{{ loop.index }}. - Fixed — used verbatim. A fixed value that happens to look like
{{ … }}stays literal.
What is resolved, and what never is:
| Resolved | Always literal |
|---|---|
| The cells an insert, update or upsert writes | The table's name, and a rename's new name |
| Every condition's comparison value | A column's name, and the column's type |
| The picked table's id |
Only Insert behaves per-item for an array. update, delete, upsert, exists and all six
table operations act on a selection, so to run one of them once per item, put the node inside a
Loop — {{ loop.item.… }} resolves in a mapping row and in
a condition value exactly as it does anywhere else.
Conditions
Get, Update, Delete, Upsert and Row exists filter with a list of
column · operator · value rows, combined by one setting:
| Match | Meaning |
|---|---|
| All | Every condition must hold (AND). The default. |
| Any | At least one must hold (OR). |
A condition may only name an author column — not id, createdAt or updatedAt.
Up to 50 conditions in one filter.
Operators
| Operator | Reads as | boolean | date | number | string |
|---|---|---|---|---|---|
eq | is | ✔ | ✔ | ✔ | ✔ |
neq | is not | ✔ | ✔ | ✔ | ✔ |
gt | is after / greater than | — | ✔ | ✔ | ✔ |
gte | is at or after / ≥ | — | ✔ | ✔ | ✔ |
lt | is before / less than | — | ✔ | ✔ | ✔ |
lte | is at or before / ≤ | — | ✔ | ✔ | ✔ |
isEmpty | is empty | ✔ | ✔ | ✔ | ✔ |
isNotEmpty | is not empty | ✔ | ✔ | ✔ | ✔ |
contains | contains | — | — | — | ✔ |
startsWith | starts with | — | — | — | ✔ |
contains and startsWith apply to string columns only. They ignore case and treat the value
as plain text, so %, _ and \ match themselves. An empty value is refused.
A Boolean column offers no ordering operators: gt on a Boolean is a comparison that means
nothing, so the panel does not offer it and the server refuses it by name.
isEmpty and isNotEmpty take no value, and the panel hides the value field for them. Empty
means null or text that is blank, and the same definition is used by the filter and by the write
path, so the two can never disagree about the word.
{
"resource": "row",
"operation": "get",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Contract register",
"conditions": [
{ "id": "cond-1", "column": "Status", "operator": "eq", "value": "open", "valueMode": "fixed" },
{
"id": "cond-2",
"column": "Deadline",
"operator": "lte",
"value": "{{ upstream.horizon-1.output.fields.cutoff }}",
"valueMode": "expression"
}
],
"conditionMatch": "all",
"orderBy": [{ "id": "sort-1", "field": "Deadline", "direction": "asc" }],
"limit": 50,
"returnAll": false
}Ordering and how much comes back
| Field | Applies to | Meaning |
|---|---|---|
| Order by | Row · Get | Any author column or system field, each ascending or descending. Up to 8 keys. |
| Order by | Table · List | name, createdAt or updatedAt only — anything else is refused by name. |
| Limit | Row · Get, Table · List | Left empty, the server's own default page size applies. Never "no limit". |
| Return all matching rows | Row · Get | Overrides Limit, still bounded by the platform's per-read ceiling. |
| Cursor | Row · Get | The nextCursor of a previous page. Empty reads the first page. |
| Read as | Row · Get | Rows (a page, in the output) or File (all rows) (one JSONL file). |
Paging with a cursor
A Get in the default order (oldest first) outputs result.nextCursor beside its rows — the
place the next page starts, or null once the last page has been read. Feed it back into Cursor
to read the next page, typically inside a Loop:
{
"resource": "row",
"operation": "get",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Open invoices",
"limit": 1000,
"cursor": "{{ upstream.page-1.output.result.nextCursor }}"
}A cursor never skips or repeats a row, even while other runs add rows between pages. It follows the
order rows were added, so it cannot be combined with Order by (a page ordered by Order by
has no nextCursor), and a cursor from another table is refused.
Reading every row as a file
Read as: File (all rows) writes every matching row — not just one page — to one JSONL file
and outputs { file, rowCount }. It is bounded by the table's own row limit rather than the
per-read ceiling, so a later step can process the whole register: a Code node, or a bulk upsert
into another table. The conditions still apply; Order by, Limit, Return all and Cursor do not.
The file is also bounded by the row source file size limit (50 MB by default), so every export
can be read straight back in as a bulk upsert's row source; a larger export is refused with the
limit named. Add conditions to export the rows in parts.
{
"resource": "row",
"operation": "get",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Open invoices",
"readOutput": "file"
}If the run retries the step, it gets the same file back rather than a second copy.
Flags and guards
Dry Run
On Update, Delete, Upsert and Delete table. Reports what would change and writes
nothing; the node still outputs the affected count, so a run can be checked before the flag comes
off. An update or a delete reports before and after values for each row; an upsert that would
insert reports the cells it would write as result.insertValues, because a row that does not exist
yet has no "before".
Optimize Bulk
On Insert. Writes every row in one statement and returns a count instead of the rows. Use it
for a large insert whose inserted rows no later node reads — under it, result.rows is absent.
Apply to all rows
On Update and Delete. An update or a delete with no conditions touches every row in the table, so the node refuses to run until you tick this box. It is read off the absence of conditions, not off a row count.
An Upsert is offered no such box: an upsert with no key is refused outright, with no escape hatch, because its conditions are the key it races on.
You find out before the run, not during it. The panel shows the missing key under Conditions
and the node wears a red ! on the canvas; Publish and a Studio Run are refused with a
message naming the node; and elizon validate, build and install report it as the
DATA_TABLE_NODE_KEY_MISSING warning. Saving is never refused, so a half-built canvas stays saved.
Allow deleting tables
On Delete table, and false by default. A run has no role to lean on, so this flag is the
authority that lets a workflow drop a table. Without it the node refuses, and the panel warns until
it is ticked.
{
"resource": "table",
"operation": "delete",
"tableId": "8a9f0e42-1d7c-4a3a-9c2e-6b4d1f77e001",
"tableName": "Scratch register",
"dryRun": true,
"allowTableDelete": true
}Return existing if it exists
On Create table. Makes a create-on-every-run workflow idempotent: a name already taken returns
the existing table with result.created as false, instead of failing the run.
What the node outputs
Every operation outputs the same envelope, so an execution_nodes row reads as
row · insert · Contract register · 3 without opening the payload:
| Field | Type | Meaning |
|---|---|---|
resource | "table" | "row" | What the node acted on |
operation | string | The operation it ran |
table.id | string | The table as the server resolved it — null for List tables |
table.name | string | ” |
affected | number | Rows (or tables) created, read, changed, deleted or counted |
dryRun | boolean | True when it wrote nothing |
result | object | The operation's own output — the only place row contents appear |
result depends on the operation:
| Operation | result fields |
|---|---|
| Table · Create | table.id, table.name, table.columns, created |
| Table · List | tables (with row counts) |
| Table · Rename, Add column, Remove column | table |
| Table · Delete | deletedRows, rows |
| Row · Insert | inserted, rows (absent under Optimize Bulk), ignoredFields |
| Row · Get | rows, nextCursor |
| Row · Get, read as a file | file, rowCount |
| Row · Bulk upsert | inserted, updated, unchanged, total (counts only, never the rows) |
| Row · Update | updated, rows, changes (Dry Run) |
| Row · Delete | deleted, rows |
| Row · Upsert | action ("inserted" / "updated"), updated, rows, insertValues |
| Row · Exists | exists, count |
Downstream, branch on a plain value:
{{ upstream.<node id>.output.result.exists }}
{{ upstream.<node id>.output.affected }}
{{ upstream.<node id>.output.result.rows }}Limits and failures
The platform limits apply to a node exactly as they do to a person: a write that would cross one fails the node with the limit named, and a read asking for more rows than the ceiling allows is clamped to it.
A node fails — rather than writing something approximate — when:
- a value cannot be coerced to its column's type (the message names the column, the type and the value),
- a condition names a column the table does not have, or an operator its type does not accept,
- an Update or Delete has no conditions and no Apply to all rows,
- an Upsert has no conditions at all,
- a Delete table has no Allow deleting tables,
- a
listis ordered by anything but the three fields it can sort by, - a Bulk upsert has no key column, a row with no key value, or two rows with the same key,
- a row source file is not valid JSON or JSONL (the message names the first bad line), is no longer available, or is larger than the row source file limit,
- a Get cursor is malformed, comes from another table, or is combined with Order by,
- the table belongs to another project or another organization — including when the node names its real id,
- an operation runs past its statement timeout (see When a statement times out).