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.
How Data Stores Work
Section titled “How Data Stores Work”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.
| Concept | Description |
|---|---|
| Table | A named collection of key-value entries (like a database table) |
| Key | A unique identifier within a table (up to 1,024 characters) |
| Value | Any JSON-serializable data — strings, numbers, booleans, objects, or arrays |
Creating a Table
Section titled “Creating a Table”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.
Data Store Steps
Section titled “Data Store Steps”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.
| Field | Description |
|---|---|
| Table | Select or type a table name |
| Key | The 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 objectsStore or update a value. If the key already exists, its value is replaced.
| Field | Description |
|---|---|
| Table | Select or type a table name |
| Key | The key to store under (supports templates) |
| Value | Key-value pairs defining the data to store |
| Expiration | Optional TTL — the entry is auto-deleted after this time |
Expiration Settings
Section titled “Expiration Settings”| Option | Description |
|---|---|
| No expiration | Entry persists until manually deleted (default) |
| Custom TTL | Set 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 writtenList all keys in a table with optional substring filtering on the key name.
| Field | Description |
|---|---|
| Table | The table to list keys from |
| Key contains | Optional — return only keys whose name contains this substring (case-insensitive). Note: this is a substring/contains match, not a strict prefix. |
| Limit | Max results to return (1–1,000, default 100) |
| Offset | Skip 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 existSearch entries by their stored values using field-level filters — like a simple database query.
| Field | Description |
|---|---|
| Table | The table to query |
| Filters | One or more conditions on value fields |
| Filter Mode | All (every filter must match) or Any (at least one must match) |
| Limit | Max results (1–1,000, default 100) |
| Offset | Skip results for pagination |
Each filter has three parts:
| Part | Description | Example |
|---|---|---|
| Field | Dot-notation path into the stored value | status, user.email, tags |
| Operator | Comparison type | equals, contains, greater than, etc. |
| Value | The value to compare against | active, 100, true |
Available operators:
| Operator | Description |
|---|---|
equals | Exact match |
not_equals | Not equal |
contains | Text contains substring |
array_contains | Array includes a value |
gt | Greater than |
gte | Greater than or equal |
lt | Less than |
lte | Less 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 existFor 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.
Delete
Section titled “Delete”Remove a single entry by key.
| Field | Description |
|---|---|
| Table | The table to delete from |
| Key | The key to delete (supports templates) |
Output:
{{ delete-user.deleted }} // true if the key existed and was deleted{{ delete-user.key }} // the key that was deletedAtomic Concurrency Primitives
Section titled “Atomic Concurrency Primitives”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:
| Step | What it does | Used in |
|---|---|---|
data-store.atomic-update | Conditionally update one row by key. | Token bucket, Queue dispatcher, Stale-claim recovery |
data-store.atomic-claim | Claim N rows matching a filter. | Queue dispatcher, Stale-claim recovery |
data-store.atomic-upsert | Insert if absent, or mutate on conflict. | Process event once, Lease acquisition, Token bucket, Rate limit per key |
Shared building blocks
Section titled “Shared building blocks”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.operationis eitherset(overwrite) orincrement(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.
Atomic Update
Section titled “Atomic Update”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.
| Field | Description |
|---|---|
| Table | The table containing the row |
| Key | Exact key of the row (supports templates) |
| Guard Conditions (optional) | Preconditions on the current value; the update applies only if they match |
| Match | all (AND) or any (OR) for combining guards |
| Set | One 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 targetedatomic-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.
Atomic Claim
Section titled “Atomic Claim”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.
| Field | Description |
|---|---|
| Table | The queue table |
| Select Rows (optional) | Conditions identifying claimable rows (e.g. status equals pending) |
| Match | all or any for combining conditions |
| Claim Mutation | How 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 By | Column (createdAt or recordTimestamp) and direction. Default: FIFO (createdAt asc). |
| Limit | Max 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 valueAn empty claimed array means nothing was available, not an error.
Atomic Upsert
Section titled “Atomic Upsert”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.
| Field | Description |
|---|---|
| Table | The table |
| Key | The conflict key |
| Insert Value | Full value to insert when the row doesn’t yet exist |
| Update Mutation | Mutations 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 keyConcurrency Patterns
Section titled “Concurrency Patterns”Idempotency / Process Once
Section titled “Idempotency / Process Once”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.
| Step | Configuration |
|---|---|
| Atomic Upsert | Table: processed_events, Key: {{ initial.eventId }}, Insert Value: {"claimedAt": "{{ $util.now }}", "payload": "{{ initial.payload }}"}, Update Mutation: (empty list — pure seed-if-missing semantics) |
| If | Condition: {{ 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.
Token Bucket (rate limiting)
Section titled “Token Bucket (rate limiting)”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):
| Step | Configuration |
|---|---|
| Atomic Upsert | Table: rate_limits, Key: external-api, Insert Value: {"tokens": 100}, Update Mutation: (empty — nothing to do on conflict) |
Decrement on each call:
| Step | Configuration |
|---|---|
| Atomic Update | Table: rate_limits, Key: external-api, Guard: tokens gt 0, Set: tokens increment -1 |
| If | Condition: {{ 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.
Rate limit per key
Section titled “Rate limit per key”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.
Lease Acquisition
Section titled “Lease Acquisition”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.
| Step | Configuration |
|---|---|
| Atomic Upsert | Table: leases, Key: dispatcher, Insert Value: {"holder": "{{ $execution.id }}", "expiresAt": "..."}, Update Mutation: (empty — leave the existing lease alone) |
| If | Condition: {{ 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.
Queue Drain (Producer / Dispatcher)
Section titled “Queue Drain (Producer / Dispatcher)”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:
| Step | Configuration |
|---|---|
| Atomic Claim | Table: pending_ops, Select Rows: status equals pending, Order By: createdAt asc (FIFO), Limit: 10 |
| For Each | Items: {{ 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.
Stale-Claim Recovery
Section titled “Stale-Claim Recovery”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:
| Step | Configuration |
|---|---|
| Atomic Claim | Table: 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 Each | Process exactly as in the main drain |
The default atomic-claim set rows already write claimedAt = {{ $util.now }}, so this works out of the box.
What’s not supported
Section titled “What’s not supported”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-claimis for.atomic-updateis single-row by design. - Claim-and-delete.
atomic-claimmarks rows in place (at-least-once delivery). Disposable queues would need a futuremode: 'delete'. - Priority queues (ordering by a stored value field).
orderBysupportscreatedAtandrecordTimestamponly. - A reaper daemon for stuck rows. Use the stale-claim recovery pattern above instead.
Browsing Data in the UI
Section titled “Browsing Data in the UI”The Data Store page lets you browse, search, edit, and manage your data store entries directly from the QuickFlo UI.
Tables Sidebar
Section titled “Tables Sidebar”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.
Records Table
Section titled “Records Table”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
Section titled “Detail Panel”
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.
Searching and Filtering
Section titled “Searching and Filtering”The search bar supports two modes:
Key search — type any text to filter entries by key (case-insensitive contains match):
user-123Value search — use field:value syntax to filter by stored values. Click the Search button or press Enter to execute:
| Syntax | Description | Example |
|---|---|---|
field:value | Field contains value (partial match) | status:active |
field:=value | Field equals value (exact match) | email:=john@example.com |
field:!value | Field does not contain value | status:!deleted |
field:!=value | Field does not equal value | status:!=inactive |
[].field:value | Array element field contains value | [].level:123 |
field[]:value | Array field contains primitive value | tags[]:important |
Active filters appear as badges below the search bar that you can dismiss individually.
Editing Records
Section titled “Editing Records”
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.
Export and Import
Section titled “Export and Import”
Export
Section titled “Export”Export an entire table as a JSON file from the table toolbar. The export contains an array of key-value objects.
Import
Section titled “Import”
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.
Sharing Tables Across Organizations
Section titled “Sharing Tables Across Organizations”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.
Creating a Share
Section titled “Creating a Share”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.
| Field | Description |
|---|---|
| Source table | The table you’re sharing (read-only) |
| Target organization | The org that gains read access |
| Alias | Optional 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.
What the Partner Sees
Section titled “What the Partner Sees”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.
Revoking a Share
Section titled “Revoking a Share”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.
Visualizing Data
Section titled “Visualizing Data”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.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Key length | 1,024 characters |
| Table name length | 255 characters |
| Minimum TTL | 1 minute |
| Maximum TTL | No expiration (entries persist indefinitely) |
| Default TTL (when expiration enabled) | 30 days |
| Query limit per request | 1,000 entries |
| Bulk import per request | 50,000 entries |