Signals, Queries, and Updates

On this page
Open the guided lab ↗

From job to conversation

A Workflow that only runs is a job. A Workflow you can pause, question and re-instruct while it runs is something else, and upstream names it: "Workflows can be thought of as stateful web services that can receive messages." Today the agent grows endpoints — and keeps them across Worker deaths.

Three verbs

Temporal supports three types of messages. The definitions are upstream's, kept exact:

Signal Query Update
Durable? Yes — recorded as WorkflowExecutionSignaled; delivered even if no Worker is alive No — answered by a live Worker, nothing persisted Yes once accepted — WorkflowExecutionUpdateAccepted, then ...Completed
Returns a value? No Yes Yes — result or error
May mutate state? Yes No — "must not mutate" Yes
In history? Yes Never Accepted: yes. Rejected by a validator: no
Needs a Worker to be up? No — the call returns when the Service accepts it Yes Yes — the client waits for the Worker to accept
Works on a closed Workflow? No Yes, within the retention period No

For reads, "you normally want to do a Query, because Queries are efficient — they never add entries to the Workflow Event History." For writes the question is whether the caller must know the write took effect: fire-and-forget is a Signal; "clients need a result or exception without performing a query" is an Update.

Swipe to explore diagram →
Signal, Query, and Update message paths A Signal is recorded by the Service and handled later. A Query reads state through a live Worker without a history Event. An Update reaches a Worker for validation; accepted Updates enter history and rejected Updates leave no Event. Signal Query Update send pauseService logsmessage Worker handleslater ask statuslive Workerreads state return valueno Event change_goalWorkervalidates accept: Event reject: none
The three message paths differ at the history boundary: a Signal is recorded before a Worker handles it, a Query leaves no Event, and an Update reaches history only after a Worker accepts it.

Handlers are Workflow code

Handlers are methods on the Workflow class, decorated @workflow.signal, @workflow.query or @workflow.update. They run inside the Workflow sandbox, so every determinism rule from module 3 applies to them. Behind the scenes Temporal runs a loop: "every time the Workflow wakes up ... it will process messages in the order they were received, followed by making progress in the Workflow's main method."

Swipe to explore diagram →
Worker wakes a new Workflow Task handlers run Signals and Updates, in arrival order main method runs until its next await
One Workflow Task: queued messages are handled first, in the order they arrived, then the main method resumes.

"This execution is on a single thread — while this means you don't have to worry about parallelism, you do need to worry about concurrency if you have written Signal and Update handlers that can block." A def handler is atomic; an async def handler is not (see "Handlers interleave").

Note the ordering: messages are processed before the first run of the main method, which happens with Signal-With-Start, a backlogged Task Queue, or a message that lands right after a continue-as-new. So initialize state in __init__ — decorate it @workflow.init if it needs the Workflow's input — and never rely on the main method having run.

The agent's control surface

Signals for control, a Query for status, an Update for a change the caller must see confirmed:

import asyncio
from dataclasses import dataclass
from temporalio import workflow

@dataclass
class AgentInput:
    goal: str

@workflow.defn
class AgentRun:
    @workflow.init
    def __init__(self, input: AgentInput) -> None:
        self.goal = input.goal
        self.step = 0
        self.paused = False
        self.cancelled = False
        self.injected: list[str] = []
        self.processed_command_ids: set[str] = set()
        self.lock = asyncio.Lock()

    @workflow.run
    async def run(self, input: AgentInput) -> str:
        while not self.cancelled and not self.done():
            # pause: the main loop blocks here; nothing polls, no Worker is required
            await workflow.wait_condition(lambda: not self.paused or self.cancelled)
            if self.cancelled:
                break
            await self.next_step()          # LLM call, tool call: Activities (module 4)
            self.step += 1
        await workflow.wait_condition(workflow.all_handlers_finished)
        return self.summary()

    @workflow.signal
    def pause(self) -> None:
        self.paused = True

    @workflow.signal
    def resume(self) -> None:
        self.paused = False

    @workflow.signal
    def cancel_run(self) -> None:
        self.cancelled = True

    @workflow.signal
    def inject_context(self, text: str, command_id: str) -> None:
        if command_id in self.processed_command_ids:
            return                          # a retried Signal; see "Message idempotency"
        self.processed_command_ids.add(command_id)
        self.injected.append(text)

    @workflow.query
    def status(self) -> dict:
        return {"goal": self.goal, "step": self.step, "paused": self.paused}

    @workflow.update
    async def change_goal(self, goal: str, command_id: str) -> str:
        if command_id in self.processed_command_ids:
            return self.goal                # already applied; answer, do not re-apply
        async with self.lock:               # serialize with anything else that touches goal
            self.processed_command_ids.add(command_id)
            self.goal = goal
            self.injected.append(f"goal changed to: {goal}")
        return self.goal

    @change_goal.validator
    def validate_goal(self, goal: str, command_id: str) -> None:
        if not goal.strip():
            raise ValueError("goal must not be empty")

workflow.wait_condition(fn) is how Workflow code waits for a message: it takes a function returning True or False, optionally a timeout, and resolves when a handler makes the condition true. Upstream lists its three jobs — wait for a Signal or Update to arrive, wait inside a handler until it is appropriate to continue, and wait in the main method until all handlers have finished. A Query handler must be a plain def: it cannot execute an Activity or await anything.

From the client, each verb is one call on the handle:

handle = client.get_workflow_handle_for(AgentRun.run, "agent-42")
await handle.signal(AgentRun.pause)                       # returns when the Service records it
print(await handle.query(AgentRun.status))                 # {'goal': ..., 'step': 27, 'paused': True}
new_goal = await handle.execute_update(AgentRun.change_goal, args=["write the tests first", "cmd-9f1e"])

A paused Workflow consumes no Worker compute while waiting

pause arrives at step 27. The handler flips a flag; the main loop blocks on wait_condition. Now every Worker can disappear. Nothing polls for this execution and no process holds its state — the WorkflowExecutionSignaled Event is in history, and history is the state.

Two days later resume arrives. A Worker picks up the Workflow Task, replays history to step 27 (module 2), sees paused become False, and runs step 28. No process waited two days, and the paused run consumed no Worker compute while it waited. This is durable stateful execution, not a job runner.

Note

Temporal also has an operator-level Workflow Pause (temporal workflow pause; self-hosted requires Server v1.30+ with frontend.WorkflowPauseEnabled, as of 2026-09 — see the upstream page). It stops Workflow Task dispatch from outside your code, and while paused "Updates and Queries are rejected". The agent's pause Signal is application logic; the operator control is an incident tool. Different layers, same word.

Update, up close

An Update is a two-stage write, and the validator is what makes the first stage cheap. "If it accepts, the Update will become part of your Workflow's history and the client will be notified that the operation has been Accepted. The Update handler will then run until it returns a value. If it rejects, the client will be informed that it was Rejected, and the Workflow will have no indication that it was ever requested, similar to a Query handler." Like a Query, a validator may not block, so it can check arguments and current state but not call an Activity.

On the client, execute_update waits for completion and returns the handler's value. start_update(..., wait_for_stage=WorkflowUpdateStage.ACCEPTED) returns a handle as soon as the Worker has accepted it, useful when the handler goes on to run Activities. Either way, a rejection or a handler failure surfaces as WorkflowUpdateFailedError in the caller:

from temporalio.client import WorkflowUpdateFailedError

try:
    await handle.execute_update(AgentRun.change_goal, args=["   ", "cmd-9f20"])
except WorkflowUpdateFailedError as e:
    print("rejected:", e.cause)        # ValueError: goal must not be empty

What you raise inside the handler matters. An ApplicationError (or an Activity that exhausts a finite retry policy) fails the Update and the client gets the error; the Workflow keeps going. Any other exception "will cause a Workflow Task Failure. This means the Workflow will get stuck and will retry the handler periodically until the exception is fixed" — and the client waits. The same rule for Signals is harsher: an ApplicationError in a Signal handler fails the whole Workflow, so raise one there only for unrecoverable errors.

Handlers interleave

An async def handler can await an Activity, a Timer or a wait_condition, and the Python guide is explicit: "handler executions and your main Workflow method are all running concurrently, with switching occurring between them at await calls." Suppose change_goal grows: before swapping the goal it runs an Activity that rewrites context_summary for the new objective. At step 27 three messages arrive within a second — Update A change_goal("investigate latency"), Update B change_goal("switch model"), then a pause Signal — and the main loop, which checks is_continue_as_new_suggested() each step, decides to continue-as-new while A's handler is still awaiting its Activity.

What ordering is promised? Only that on each wake-up the Workflow processes messages "in the order they were received, followed by making progress in the Workflow's main method." Completion order is not promised. A's handler starts first and yields at its await; B's handler runs to its own await before A resumes.

Can two Updates interleave? Yes, at every await. If A resumes after B has written self.goal, A overwrites it with the older goal and returns "investigate latency" to a client that saw B accepted later. The pause Signal is a plain def, so it runs atomically between two of those switches and only sets a flag the main loop reads. Upstream's word for this is reentrant.

What happens at continue-as-new? "Your main Workflow method can return or continue-as-new while a handler is still waiting on an async task, such as an Activity result." The handler is cut off: its Activity result is never applied, the new run's AgentState carries whichever goal was written last, and the client that called execute_update for A gets a Not Found error. Temporal's guidance: "If you use Updates or Signals, don't call Continue-as-New from the handlers. Instead, wait for your handlers to finish in your main Workflow before you run continue_as_new" — that is, await workflow.wait_condition(workflow.all_handlers_finished) before the call, so A completes or fails first.

How do you make it safe? Four moves; the code above already makes two. Serialize the critical section with asyncio.Lock, which the sandbox provides deterministically, so B's read-modify-write cannot start until A's has finished and each Update sees a consistent goal. Dedupe on command_id in processed_command_ids, so a client retry of A does not run the Activity again. Reject conflicting work in the validator — a goal equal to the current one, or a change while self.paused is set — because a rejected Update leaves no Event and no handler to drain. And never continue-as-new from a handler; the main loop drains, then snapshots. Lab 5.5 stages the race.

Safe handlers

Three notes from the upstream safe-handler sample.

Drain before you finish. "You should generally finish running all handlers before the Workflow run completes or continues as new." The Worker warns when a run ends with unfinished handlers; @workflow.update(unfinished_policy=workflow.HandlerUnfinishedPolicy.ABANDON) silences the warning per handler if abandoning is what you mean.

Serialize handlers that share state. Guard the section with asyncio.Lock(), as change_goal does, or have handlers enqueue work for the main loop — the hello_signal pattern with asyncio.Queue.

Deliver exactly once. Updates are deduplicated by the Service on their Update ID (a UUID by default, settable by you), but "Update ID deduplication by the server is per Workflow run", so across continue-as-new you keep your own record. Signals carry no ID: put an idempotency key in the payload and dedupe in Workflow code. Both kinds also dedupe retried client calls by request ID automatically.

Message idempotency

The client that sends inject_context is an API server. Its connection to the Service breaks after the Service recorded WorkflowExecutionSignaled but before the acknowledgement arrived; the client's retry loop sends the Signal again. Now self.injected holds the note twice and the model reads it twice. The same shape hits change_goal: the Update completed, the response was lost, the client re-sends, and a second goal changed to: note lands in the context.

Temporal cannot dedupe this for you: a Signal has no identity beyond its payload, and an Update's server-side dedupe holds only within one run and only for the client's own request id, not a new request built after a reconnect. Upstream's design-pattern page for Signal-With-Start shows the fix as the first line of the handler: the Workflow keeps a set of processed operation ids and ignores a Signal whose id it has seen. AgentRun does the same with command_id, chosen by the caller once per command and reused on every retry — a UUID minted when the user pressed the button, not when the request was sent:

if command_id in self.processed_command_ids:
    return                                  # duplicate: ignore, or for an Update, return the earlier answer
self.processed_command_ids.add(command_id)

Three consequences. The set is Workflow state, so it is rebuilt by replay and survives any Worker death; nothing external is consulted. It must ride along in the AgentState snapshot at continue-as-new (module 6), or the first Signal after a new run starts is an undetected duplicate. And it grows: bound it — keep the last few hundred ids, or ids younger than the longest client retry window. Lab 5.4 retries a Signal on purpose and reads the two WorkflowExecutionSignaled Events beside the one appended note.

Finally, messaging a Workflow that may not exist yet. Signal-With-Start (start_workflow(..., start_signal="inject_context", start_signal_args=[...])) signals a running execution or starts one and signals it, atomically. Update-With-Start (execute_update_with_start_workflow with a WithStartWorkflowOperation and an explicit WorkflowIDConflictPolicy) does the same for Updates — but "Update-With-Start is not atomic": if the Update cannot be delivered because no Worker is available, the Workflow still starts (as of 2026-09; see upstream page).

What the lab will test

A Worker is offline when a user sends a message and asks for status. Decide which operation—Signal, Query, or Update—fits each request. In Lab 5, use history to test which requests were recorded.

Try it

Lab 5 is in labs/05-signals-queries-updates/:

Sources and license

This page adapts material from Temporal's MIT-licensed documentation and samples (© Temporal Technologies Inc.; © Uber Technologies, Inc.). Adapted text is rewritten for this course; the upstream pages are the reference of record and may have changed since the commit linked here. Temporal is a trademark of Temporal Technologies; this course is independent and not endorsed by Temporal.