Skip to content

Index workflows with search attributes ​

Search attributes are indexed fields on Temporal's visibility store. They let you find executions by domain criteria — "every pending order for customer X", "all high-priority jobs started this week" — instead of only by workflow id.

Declaring them on the contract constrains both the keys and their value types.

Declare them ​

typescript
import { defineSearchAttribute, defineWorkflow } from "@temporal-contract/contract";
import { z } from "zod";

export const processOrder = defineWorkflow({
  input: OrderSchema,
  output: OrderResultSchema,
  // Search attributes are this doc's topic, but `startPolicy` is required on
  // every workflow. `retry-if-failed` is the right mode for this shape — an
  // order that charged a customer must not be re-runnable after a Completed
  // run. See "Declare idempotency" in define-a-contract.md.
  startPolicy: "retry-if-failed",
  searchAttributes: {
    customerId: defineSearchAttribute({ kind: "KEYWORD" }),
    orderTotal: defineSearchAttribute({ kind: "DOUBLE" }),
    priority: defineSearchAttribute({ kind: "INT" }),
    placedAt: defineSearchAttribute({ kind: "DATETIME" }),
    tags: defineSearchAttribute({ kind: "KEYWORD_LIST" }),
    expedited: defineSearchAttribute({ kind: "BOOL" }),
    notes: defineSearchAttribute({ kind: "TEXT" }),
  },
});

The seven kinds map to TypeScript like this:

kindTypeScript typeNotes
TEXTstringTokenized; full-text search, not exact match
KEYWORDstringExact match. The right choice for ids and enums
INTnumber64-bit integer
DOUBLEnumberFloating point
BOOLboolean
DATETIMEDate
KEYWORD_LISTstring[]Exact match against any element

TEXT vs KEYWORD

TEXT is analyzed and tokenized — searching it does substring/word matching, and it cannot be used in ORDER BY. KEYWORD is stored verbatim and matches exactly. For an id, status, or enum you almost always want KEYWORD.

defineContract rejects a name Temporal reserves for its own system attributes (WorkflowId, ExecutionStatus, StartTime, …), and one attribute declared with two different kinds across workflows — the namespace has one type per name.

Register them on the server ​

Declaring an attribute on the contract does not create it on the cluster. Register it once per namespace before use:

bash
temporal operator search-attribute create \
  --name customerId --type Keyword \
  --namespace default

temporal operator search-attribute create --name orderTotal --type Double
temporal operator search-attribute create --name priority   --type Int
temporal operator search-attribute create --name placedAt   --type Datetime
temporal operator search-attribute create --name tags       --type KeywordList
temporal operator search-attribute create --name expedited  --type Bool
temporal operator search-attribute create --name notes      --type Text

On Temporal Cloud, use the UI or tcld. Starting a workflow with an unregistered attribute fails.

Set them when starting a workflow ​

typescript
const result = await client.executeWorkflow("processOrder", {
  workflowId: "order-123",
  args: order,
  searchAttributes: {
    customerId: "CUST-456",
    orderTotal: 249.99,
    priority: 1,
    placedAt: new Date(),
    tags: ["expedited", "gift"],
    expedited: true,
  },
});

Keys and value types are checked against the contract. An undeclared key, or a Date where the contract says KEYWORD, is a compile error. The same check runs again at runtime, so a value that slips past the types (a cast, an untyped caller) is a defect rather than a silently mis-indexed workflow. Every attribute is optional — set only the ones you have.

The same option works on startWorkflow, signalWithStart, executeUpdateWithStart, and schedule.create.

Read them back ​

readTypedSearchAttributes turns Temporal's untyped TypedSearchAttributes instance into a typed partial object:

typescript
import { readTypedSearchAttributes } from "@temporal-contract/client";

const handle = client.getHandle("processOrder", "order-123"); // synchronous, no I/O

const described = await handle.describe();
if (described.isOk()) {
  const attrs = readTypedSearchAttributes(
    orderContract.workflows.processOrder,
    described.value.typedSearchAttributes,
  );

  attrs.customerId; // string | undefined
  attrs.orderTotal; // number | undefined
  attrs.placedAt; // Date | undefined
}

Every field is optional — an attribute that was never set is undefined.

Query executions ​

Search attributes are queried with Temporal's list filter syntax, through the underlying SDK client:

typescript
const temporalClient = new Client({ connection });

for await (const execution of temporalClient.workflow.list({
  query: `WorkflowType = 'processOrder' AND customerId = 'CUST-456' AND ExecutionStatus = 'Running'`,
})) {
  console.log(execution.workflowId, execution.status.name);
}

More examples:

sql
-- High-value orders placed today
orderTotal > 1000 AND placedAt > '2026-07-28T00:00:00Z'

-- Any expedited or gift order
tags IN ('expedited', 'gift')

-- Most recent first
WorkflowType = 'processOrder' ORDER BY placedAt DESC

Schedule-spawned runs ​

Attributes set on a schedule apply to each run it spawns, so scheduled and directly started executions are indexed identically:

typescript
await client.schedule
  .create("processOrder", {
    scheduleId: "nightly-reconcile",
    spec: { cronExpressions: ["0 2 * * *"] },
    args: { mode: "reconcile" },
    searchAttributes: {
      priority: 5,
      tags: ["scheduled"],
    },
  })
  .getOrThrow();

Keep the cardinality sane ​

Visibility indexes are not a general-purpose database:

  • Do index fields you filter or sort operational queries by.
  • Don't index free-form user content, large payloads, or anything with near-unique values per execution beyond the ids you actually search on.
  • Don't treat them as workflow state. They are metadata for finding executions; the workflow's own state belongs in its input and in queries.

Next ​

Released under the MIT License.