Data Table Block
Read and write rows in user-managed data tables from a workflow step
The Data Table block performs CRUD operations against a user-managed data table. Use it to persist workflow state across runs, build small lookup tables, deduplicate records, or share data between workflows.
Data tables are first-class objects in the workspace — you create the table once in the Studio (Data section) or via the Tables API, then any workflow can read and write its rows.
Data tables are scoped to a company. Every operation is tenant-isolated — a workflow running in company A can never read or write a table that belongs to company B.
When to Use
- Workflow state. Track which records have already been processed so subsequent runs skip them.
- Lookups. Map incoming external IDs to internal IDs without round-tripping to a CRM.
- Light reporting. Append a row per run for downstream aggregation in your BI tool.
- Approval queues. Insert a row representing pending work that a human resolves out-of-band.
For larger volumes, frequent reads, or relational data, use a real database via the Code block or a dedicated integration. Each table is capped at maxRows rows (default 10,000, max 100,000).
Configuration
Operation
The Data Table block dispatches on the operation field:
| Operation | Behaviour |
|---|---|
insert | Append a new row. Fails if the table is at maxRows. |
update | Apply a JSONB merge to every row whose data->>matchKey equals matchValue. |
upsert | Update if a match exists, otherwise insert. |
delete | Delete every row matching matchKey/matchValue. |
find | Return rows whose data contains the provided filter JSON, ordered by createdAt DESC. |
getById | Look up a single row by its row UUID. Throws if not found. |
Inputs
| Field | Type | Required for | Description |
|---|---|---|---|
operation | string | all | One of the operations above. |
tableId | string (UUID) | all | Target table. Populated by the Studio from GET /api/v1/tables. |
data | object | insert, update, upsert | Row payload. For update/upsert, merged into the existing row's data with the Postgres ` |
matchKey | string | update, upsert, delete | Column name on the row used to find existing records. Compared as text via data->>matchKey. |
matchValue | string | update, upsert, delete | Value to compare against. Coerced to text — references like {{steps.previous.result.id}} resolve at runtime. |
filter | object | find | JSONB containment predicate (data @> filter). |
limit | number | find | Max rows returned (default 50, max 500). |
rowId | string (UUID) | getById | Row UUID to retrieve. |
Outputs
| Output | Type | Description |
|---|---|---|
result | json | Operation result — the inserted/found/fetched row, an { updated: number } count, an { rows: [...] } list, or an { deleted: number } count depending on the operation. |
success | boolean | true when the operation finished without throwing. |
Examples
Insert a row after a webhook fires
{
"type": "data_table",
"config": {
"operation": "insert",
"tableId": "{{tables.processed_invoices.id}}",
"data": {
"stripeInvoiceId": "{{trigger.data.object.id}}",
"customerEmail": "{{trigger.data.object.customer_email}}",
"amountCents": "{{trigger.data.object.amount_paid}}",
"processedAt": "{{run.startedAt}}"
}
}
}Upsert by an external key
{
"type": "data_table",
"config": {
"operation": "upsert",
"tableId": "{{tables.contacts.id}}",
"matchKey": "email",
"matchValue": "{{steps.parse.result.email}}",
"data": {
"email": "{{steps.parse.result.email}}",
"lastSeenAt": "{{run.startedAt}}"
}
}
}Find rows by a property
{
"type": "data_table",
"config": {
"operation": "find",
"tableId": "{{tables.queue.id}}",
"filter": { "status": "pending" },
"limit": 25
}
}The returned result.rows is the standard row shape:
[
{
"id": "row_550e8400-e29b-41d4-a716-446655440000",
"tableId": "tbl_660e...",
"companyId": "00000000-0000-0000-0000-000000000001",
"data": { "status": "pending", "...": "..." },
"position": null,
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
]Delete by external ID
{
"type": "data_table",
"config": {
"operation": "delete",
"tableId": "{{tables.processed_invoices.id}}",
"matchKey": "stripeInvoiceId",
"matchValue": "{{trigger.data.object.id}}"
}
}Error Handling
The block throws (and the step fails with STEP_ERROR) when:
- The table does not exist for the current company.
- An
insertwould exceedmaxRows. getByIdis called with a row ID that does not exist.data_tableconfig fails Zod validation (e.g.operation: "merge"instead ofupsert).
update, upsert, and delete against zero matching rows succeed and report updated: 0 / deleted: 0 — they are not treated as errors.
matchKey/matchValue is a text comparison against data->>matchKey. Numeric IDs stored as JSON numbers still compare correctly because they are stringified on both sides, but be careful with boolean or null values — pass them as the explicit strings "true", "false", or "".
Limits
| Limit | Value |
|---|---|
| Rows per table (default) | 10,000 |
| Rows per table (max) | 100,000 |
Max rows returned by find | 500 |
| Max columns | unbounded (column metadata is advisory) |
find uses a GIN index on the data JSONB column, so containment queries (data @> { ... }) stay fast even on large tables.
Tables API
REST endpoints to create tables, manage columns, and bulk-write rows from your own systems.
Code Block
For complex transforms before writing a row.
Transform Block
Reshape upstream output into the row payload.