Elizon Docs
Data Tables

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:

ControlValues
ResourceTable (act on tables themselves) or Row (act on rows in a table)
OperationOne 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

OperationWhat it doesParameters
Create tableCreates a table in the workflow's projectTable name, columns (name + type, ordered), Return existing if it exists
List tablesLists the project's tables with their row countsOrder by (name, createdAt, updatedAt), Limit
Rename tableRenames the selected tableTable, New name
Delete tableDrops the table and every row in itTable (or Table name), Dry Run, Allow deleting tables
Add columnAppends a column; existing rows are untouchedTable, Column name, Column type
Remove columnRemoves a column and strips its key from every rowTable, Column name

Resource: Row

OperationWhat it doesParameters
Insert row(s)Writes new rowsTable, Mapping (Manual / Automatic), Optimize Bulk
Get row(s)Reads matching rowsTable, Conditions, Read as (rows / file), Order by, Limit, Return all matching rows, Cursor
Update row(s)Patches the named columns of matching rowsTable, Mapping, Conditions, Dry Run, Apply to all rows
Delete row(s)Deletes matching rowsTable, Conditions, Dry Run, Apply to all rows
Upsert rowUpdates the rows the key matches, or inserts oneTable, Mapping, Conditions (the key), Dry Run
Upsert rows in bulk (by key)Upserts thousands of rows in one run, matched on key columnsTable, Match rows on (key columns), Rows from (array or file), Dry Run
Row existsReports whether anything matches, and how manyTable, 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.

node data — Row · Insert, manual mapping
{
  "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.

node data — Row · Insert, automatic mapping
{
  "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 updatedAt does 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.
node data — Row · Bulk upsert from a 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:

ResolvedAlways literal
The cells an insert, update or upsert writesThe table's name, and a rename's new name
Every condition's comparison valueA 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:

MatchMeaning
AllEvery condition must hold (AND). The default.
AnyAt 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

OperatorReads asbooleandatenumberstring
eqis✔✔✔✔
neqis not✔✔✔✔
gtis after / greater than—✔✔✔
gteis at or after / ≥—✔✔✔
ltis before / less than—✔✔✔
lteis at or before / ≤—✔✔✔
isEmptyis empty✔✔✔✔
isNotEmptyis not empty✔✔✔✔
containscontains———✔
startsWithstarts 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.

node data — Row · Get with conditions
{
  "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

FieldApplies toMeaning
Order byRow · GetAny author column or system field, each ascending or descending. Up to 8 keys.
Order byTable · Listname, createdAt or updatedAt only — anything else is refused by name.
LimitRow · Get, Table · ListLeft empty, the server's own default page size applies. Never "no limit".
Return all matching rowsRow · GetOverrides Limit, still bounded by the platform's per-read ceiling.
CursorRow · GetThe nextCursor of a previous page. Empty reads the first page.
Read asRow · GetRows (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:

node data — Row · Get, next page
{
  "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.

node data — Row · Get as a file
{
  "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.

node data — Table · Delete, guarded
{
  "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:

FieldTypeMeaning
resource"table" | "row"What the node acted on
operationstringThe operation it ran
table.idstringThe table as the server resolved it — null for List tables
table.namestring”
affectednumberRows (or tables) created, read, changed, deleted or counted
dryRunbooleanTrue when it wrote nothing
resultobjectThe operation's own output — the only place row contents appear

result depends on the operation:

Operationresult fields
Table · Createtable.id, table.name, table.columns, created
Table · Listtables (with row counts)
Table · Rename, Add column, Remove columntable
Table · DeletedeletedRows, rows
Row · Insertinserted, rows (absent under Optimize Bulk), ignoredFields
Row · Getrows, nextCursor
Row · Get, read as a filefile, rowCount
Row · Bulk upsertinserted, updated, unchanged, total (counts only, never the rows)
Row · Updateupdated, rows, changes (Dry Run)
Row · Deletedeleted, rows
Row · Upsertaction ("inserted" / "updated"), updated, rows, insertValues
Row · Existsexists, 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 list is 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).