Skip to content

Data Stores

Data stores provide managed key-value storage for your workflows. Use them to persist data across executions, maintain state between steps, and build lookup tables — all scoped to your organization and accessible from any workflow.

Data is organized into tables, each containing key-value entries. Tables are shared across all workflows in your organization, so any workflow can read or write to any table.

ConceptDescription
TableA named collection of key-value entries (like a database table)
KeyA unique identifier within a table (up to 1,024 characters)
ValueAny JSON-serializable data — strings, numbers, booleans, objects, or arrays

Tables are created automatically when you first write to them using a data store step. You can also create tables from the Data Store page in the sidebar, or from the data store step configuration — the table name dropdown lets you select existing tables or type a new name to create one.

QuickFlo provides five core step types for reading and writing data, plus three atomic primitives for concurrency (covered below). Each step is configured through the visual form builder in the workflow editor.

Retrieve a single value by key.

FieldDescription
TableSelect or type a table name
KeyThe key to look up (supports templates, e.g., user-{{ initial.userId }})

Output available to later steps:

{{ get-user.value }} // the stored value (or null if not found)
{{ get-user.found }} // true or false
{{ get-user.value.email }} // access nested fields from stored objects

Store or update a value. If the key already exists, its value is replaced.

FieldDescription
TableSelect or type a table name
KeyThe key to store under (supports templates)
ValueKey-value pairs defining the data to store
ExpirationOptional TTL — the entry is auto-deleted after this time
OptionDescription
No expirationEntry persists until manually deleted (default)
Custom TTLSet a duration from 1 minute to 1 year

A TTL turns a table into a cache: store an expensive result under a stable key, read it back until it expires, recompute on the miss. Data stores are persisted to disk, so they suit caching that tolerates minutes-to-days staleness. For sub-second caching, reach for an external cache over an HTTP step.

Output:

{{ set-user.success }} // true if stored successfully
{{ set-user.key }} // the key that was written

List all keys in a table with optional substring filtering on the key name.

FieldDescription
TableThe table to list keys from
Key containsOptional — return only keys whose name contains this substring (case-insensitive). Note: this is a substring/contains match, not a strict prefix.
LimitMax results to return (1–1,000, default 100)
OffsetSkip this many results (for pagination)

Output:

{{ list-keys.keys }} // array of key strings
{{ list-keys.total }} // total matching keys
{{ list-keys.hasMore }} // true if more results exist

Search entries by their stored values using field-level filters — like a simple database query.

FieldDescription
TableThe table to query
FiltersOne or more conditions on value fields
Filter ModeAll (every filter must match) or Any (at least one must match)
LimitMax results (1–1,000, default 100)
OffsetSkip results for pagination

Each filter has three parts:

PartDescriptionExample
FieldDot-notation path into the stored valuestatus, user.email, tags
OperatorComparison typeequals, contains, greater than, etc.
ValueThe value to compare againstactive, 100, true

Available operators:

OperatorDescription
equalsExact match
not_equalsNot equal
containsText contains substring
array_containsArray includes a value
gtGreater than
gteGreater than or equal
ltLess than
lteLess than or equal

Output:

{{ query-users.entries }} // array of { key, value } objects
{{ query-users.entries[0].key }} // first result's key
{{ query-users.entries[0].value }} // first result's value
{{ query-users.total }} // total matches
{{ query-users.hasMore }} // true if more results exist

For race-free reads-and-writes (compare-and-swap, claiming from a queue, create-or-update), see Atomic Concurrency Primitives below. They use the same filter shape Query does.

Remove a single entry by key.

FieldDescription
TableThe table to delete from
KeyThe key to delete (supports templates)

Output:

{{ delete-user.deleted }} // true if the key existed and was deleted
{{ delete-user.key }} // the key that was deleted

Two workflows (or two runs of the same workflow) that operate on the same row can race: each reads the same value, each decides independently, each writes — and the writes overlap. The atomic primitives run the read, the check, and the write as one indivisible operation, so this can’t happen.

Three primitives cover the patterns below:

StepWhat it doesUsed in
data-store.atomic-updateConditionally update one row by key.Token bucket, Queue dispatcher, Stale-claim recovery
data-store.atomic-claimClaim N rows matching a filter.Queue dispatcher, Stale-claim recovery
data-store.atomic-upsertInsert if absent, or mutate on conflict.Process event once, Lease acquisition, Token bucket, Rate limit per key

All three primitives share two pieces of configuration:

  • where — the same {field, operator, value} filter shape that Query uses, applied to the stored values. Same operators, same path syntax for nested fields.
  • set — a list of {field, operation, value} mutations applied to the value. operation is either set (overwrite) or increment (add a signed number; a missing field starts from 0).

Both accept templates: {{ $util.now }}, {{ $execution.id }}, references to prior step outputs, and so on.

Conditionally mutate a single row identified by key. The where clause is the compare-and-swap guard: the update applies only if all (or any) conditions match the row’s current value. If the guard fails, nothing changes and updated is false.

FieldDescription
TableThe table containing the row
KeyExact key of the row (supports templates)
Guard Conditions (optional)Preconditions on the current value; the update applies only if they match
Matchall (AND) or any (OR) for combining guards
SetOne or more mutations to apply when the guard matches
Return Fields (optional)Limit the returned value to specific top-level fields

Output:

{{ decrement-tokens.updated }} // true if the guard matched and the row was updated
{{ decrement-tokens.value }} // the new value if updated, else null
{{ decrement-tokens.key }} // the key that was targeted

atomic-update doesn’t create the row if it’s absent. Seed it first with atomic-upsert.

A cross-execution counter is exactly this shape: seed the row with atomic-upsert, then atomic-update with a count increment 1 mutation on each run. The increment happens inside the row, so concurrent runs never lose a count the way a separate Get-then-Set would.

Claim up to limit rows that match where, marking them via set in the same operation. The data store guarantees that two concurrent claims never grab the same row; losers skip past locked rows and continue. This is the queue-drain primitive.

FieldDescription
TableThe queue table
Select Rows (optional)Conditions identifying claimable rows (e.g. status equals pending)
Matchall or any for combining conditions
Claim MutationHow to mark claimed rows. Defaults to status → in_flight, claimedAt → {{ $util.now }} (so stale claims can be recovered), and claimedBy → {{ $execution.id }} (so claimed rows correlate to the run that holds them in traces).
Order ByColumn (createdAt or recordTimestamp) and direction. Default: FIFO (createdAt asc).
LimitMax rows per call (default 10, max 1,000)
Return Fields (optional)Limit returned value to specific top-level fields

Output:

{{ drain-queue.claimed }} // array of { key, value } that this call won
{{ drain-queue.count }} // number of rows claimed (0 = nothing available)
{{ drain-queue.claimed[0].value }} // first claimed row's value

An empty claimed array means nothing was available, not an error.

Insert a row when it doesn’t exist, or apply set to the existing row when it does. The conflict target is the (organization, table, key) uniqueness constraint. The inserted flag in the output tells you which branch fired — useful for any “first writer initializes, others update” pattern.

FieldDescription
TableThe table
KeyThe conflict key
Insert ValueFull value to insert when the row doesn’t yet exist
Update MutationMutations to apply when the row already exists (the conflict branch)
Return Fields (optional)Limit returned value to specific top-level fields

Output:

{{ seed-bucket.inserted }} // true if a new row was inserted, false if an existing row was updated
{{ seed-bucket.value }} // the resulting value
{{ seed-bucket.key }} // the key

Handle each event exactly once, even when the source delivers duplicates. The event’s unique ID becomes the dedupe key; atomic-upsert with set: [] claims the event in one operation — the first caller inserts, every subsequent delivery sees the existing row and skips.

StepConfiguration
Atomic UpsertTable: processed_events, Key: {{ initial.eventId }}, Insert Value: {"claimedAt": "{{ $util.now }}", "payload": "{{ initial.payload }}"}, Update Mutation: (empty list — pure seed-if-missing semantics)
IfCondition: {{ claim.inserted }} equals true — first delivery, do the work; else skip as duplicate

The insert is atomic, so two concurrent deliveries can’t both pass the dedupe check; one succeeds, and the others see the row already exists.

Recipe: Process Event Once — copy-pasteable workflow.

Limit how many parallel callers can proceed in a window. Each caller decrements a shared counter; when the counter hits zero, further callers lose the race and back off.

Seed the bucket once (idempotent: repeated runs leave it alone):

StepConfiguration
Atomic UpsertTable: rate_limits, Key: external-api, Insert Value: {"tokens": 100}, Update Mutation: (empty — nothing to do on conflict)

Decrement on each call:

StepConfiguration
Atomic UpdateTable: rate_limits, Key: external-api, Guard: tokens gt 0, Set: tokens increment -1
IfCondition: {{ decrement.updated }} equals false (back off, retry, or give up)

When tokens reaches 0, the guard tokens gt 0 no longer matches; the row is untouched, updated is false, and the caller knows to back off.

Rather than a fixed pool of tokens, the per-key pattern advances a shared “next available at” timestamp by a fixed interval on each call. Each caller atomically reserves its slot and waits until that slot before proceeding. Different from the token bucket in that calls space out evenly rather than burst-then-refill.

Recipe: Rate-Limited HTTP Calls — the full workflow with reset-if-stale handling.

Only one workflow at a time holds the lease (for example, a single dispatcher). The first caller creates the lease; subsequent callers see it already held and back off until it’s released.

StepConfiguration
Atomic UpsertTable: leases, Key: dispatcher, Insert Value: {"holder": "{{ $execution.id }}", "expiresAt": "..."}, Update Mutation: (empty — leave the existing lease alone)
IfCondition: {{ acquire.inserted }} equals true (you hold the lease; otherwise back off)

Lease renewal is an atomic-update against the same key with a guard on holder (only the current holder may extend) and a fresh expiresAt.

Recipe: Leader Election Lease — full workflow with the if/else branch and the release step.

Producers enqueue work into a shared table; one or more dispatchers drain at a controlled rate. The atomic claim guarantees no row is processed twice across concurrent dispatcher runs — concurrent dispatchers either get disjoint batches or one gets nothing.

Producer workflows add entries to a pending_ops table using atomic-upsert (or plain Set) with {"status": "pending", "payload": ...}.

Dispatcher workflow runs on a schedule:

StepConfiguration
Atomic ClaimTable: pending_ops, Select Rows: status equals pending, Order By: createdAt asc (FIFO), Limit: 10
For EachItems: {{ drain.claimed }} (process each claimed op via HTTP, etc.)
Atomic Update (inside For Each)Table: pending_ops, Key: {{ $item.key }}, Guard: claimedBy equals {{ $execution.id }}, Set: status → done (or status → failed with error details)

The claim marked the rows in_flight in the same step that returned them, so no other dispatcher run can pick them up. If the workflow crashes mid-process, the rows stay in_flight — which the stale-claim recovery pattern below handles.

Recipe: Async Queue Dispatcher — full workflow including stale-claim recovery as a separate scheduled run.

If a dispatcher crashes after claiming but before completing, the claimed rows are stuck at status = in_flight. A second scheduled workflow re-claims any in-flight row whose claimedAt is older than a threshold:

StepConfiguration
Atomic ClaimTable: pending_ops, Select Rows: status equals in_flight AND claimedAt lt {{ stale_threshold }}, Set: status → in_flight, claimedAt → {{ $util.now }} (re-stamps the claim)
For EachProcess exactly as in the main drain

The default atomic-claim set rows already write claimedAt = {{ $util.now }}, so this works out of the box.

These primitives are deliberately scoped:

  • Multi-step transactions that span workflow steps. Each primitive is one atomic operation, not a transaction block.
  • Bulk multi-row updates by filter. That’s what atomic-claim is for. atomic-update is single-row by design.
  • Claim-and-delete. atomic-claim marks rows in place (at-least-once delivery). Disposable queues would need a future mode: 'delete'.
  • Priority queues (ordering by a stored value field). orderBy supports createdAt and recordTimestamp only.
  • A reaper daemon for stuck rows. Use the stale-claim recovery pattern above instead.

The Data Store page lets you browse, search, edit, and manage your data store entries directly from the QuickFlo UI.

Data store page showing the tables sidebar and records list

The left sidebar lists all tables in your organization with record counts. Use the search field at the top to filter tables by name. You can also create new tables or refresh the list from the toolbar icons.

Selecting a table shows all its entries in a sortable table with Key, Created, and Updated columns. Each row has edit and delete action buttons. Click any row to open the detail panel.

Detail panel showing record metadata and value tree with syntax highlighting

Clicking a record opens a detail panel showing:

  • Metadata — size, created date, updated date, and expiry countdown (if set)
  • Value preview — the full stored value with syntax highlighting and expandable tree view
  • Clickable values — click any primitive value in the tree to quickly filter by it

From the detail panel you can edit, delete, or copy the key.

The search bar supports two modes:

Key search — type any text to filter entries by key (case-insensitive contains match):

user-123

Value search — use field:value syntax to filter by stored values. Click the Search button or press Enter to execute:

Search bar with a JSONB query filter and active filter badge
SyntaxDescriptionExample
field:valueField contains value (partial match)status:active
field:=valueField equals value (exact match)email:=john@example.com
field:!valueField does not contain valuestatus:!deleted
field:!=valueField does not equal valuestatus:!=inactive
[].field:valueArray element field contains value[].level:123
field[]:valueArray field contains primitive valuetags[]:important

Active filters appear as badges below the search bar that you can dismiss individually.

Edit record dialog showing properties mode with path/value fields and expiration option

Click the edit icon on any record (or from the detail panel) to open the edit dialog. The editor supports two modes:

  • Properties mode — visual path/value pairs with an “Add Property” button
  • JSON mode — raw JSON editor for complex values

You can also toggle Set expiration to add or remove a TTL on the entry.


Table context menu showing Export, Import, and Delete options

Export an entire table as a JSON file from the table toolbar. The export contains an array of key-value objects.

Import data dialog with JSON/CSV tabs and drag-and-drop file upload

Import data from JSON or CSV files into a table. The import dialog supports drag-and-drop file upload and shows a preview of entries before importing. Existing keys are updated (upsert) and new keys are created.


You can grant read-only access to a specific data store table from your organization to a partner organization. This is the foundation for cross-org analytics and partner dashboards — the partner can build dashboards on the shared table without ever seeing the rest of your data.

From the data store table menu, choose Share with another organization and pick the target org. The user creating the share must belong to both organizations — the picker only shows orgs you’re a member of.

FieldDescription
Source tableThe table you’re sharing (read-only)
Target organizationThe org that gains read access
AliasOptional display name the partner sees instead of the raw table name

Creating or revoking a share requires the data-stores:admin role. Listing existing shares only requires data-stores:view.

In the partner organization, the shared table appears as a data source in the dashboard builder, marked with a “shared from” badge so it’s clear it’s coming from another org. They can build pivot tables, charts, calculated fields, and global filters on top of it just like a native table — the analytics engine swaps in the owner org’s ID for WHERE clauses on shared queries.

Click Revoke on a share at any time. Revocation is a soft delete (so audit history is preserved) and takes effect immediately — any in-flight queries finish, but new queries against the shared table from the partner org return an error and any partner widgets backed by the share surface that error inline.

Any data store table can be turned into a dashboard with charts, pivot tables, filters, and calculated fields — no extra schema, no separate analytics database. Point a data source at the table and you’re done. See Dashboards for the full walkthrough.


LimitValue
Key length1,024 characters
Table name length255 characters
Minimum TTL1 minute
Maximum TTLNo expiration (entries persist indefinitely)
Default TTL (when expiration enabled)30 days
Query limit per request1,000 entries
Bulk import per request50,000 entries