Why doesn't mutating session.state directly persist in Google ADK, and what does?
answer
- state is derived, not stored directly
- there is no save_state call
- side effects ride on the event
- the local object is a snapshot
- works in memory, breaks on a database
basics
~20 sThe Session you hold is a snapshot; the session service only writes state when an Event is appended. Changes must travel as EventActions.state_delta on that event, so an in-place edit to the returned dict is lost on the next load.
solid answer
~40 sADK commits state through the event log, not through the object graph. `session_service.append_event(session, event)` is the single write path: it appends the event to history and applies `event.actions.state_delta` to the session's state, routing keys by their `app:` / `user:` prefix and dropping `temp:` ones. A `Session` you got back from `get_session` is a materialised snapshot, so assigning into `session.state` mutates your local copy and nothing else — with `InMemorySessionService` you may not even notice, because your copy and the store can be the same objects, and the bug only surfaces when you switch to a persistent service. In agent code you therefore write state through the context object ADK gives the tool or callback, which collects the changes into the delta on the event that turn emits.
code
python · 21 linesfrom google.adk.events import Event, EventActions
from google.adk.sessions import BaseSessionService, Session
async def mark_order_paid(
session_service: BaseSessionService, session: Session
) -> None:
# Wrong: edits a snapshot, never reaches the store.
# session.state["order_paid"] = True
event = Event(
author="billing_webhook",
actions=EventActions(
state_delta={
"order_paid": True,
"user:last_purchase": "A-1001",
"temp:raw_webhook": "dropped at commit",
}
),
)
await session_service.append_event(session, event)go deeper
Know that you change state through the context object ADK gives your tool, not by editing a Session you fetched, and that there is no save method.
Explain that append_event is the write path and that EventActions.state_delta carries the change, including how app:, user: and temp: prefixes are applied at commit time.
Diagnose the classic symptom — works with the in-memory service, silently loses writes on a database service — and reason about concurrent writers, last-delta-wins, and the IO cost of chatty state.
Own the implications of an append-only, event-derived state model: auditability of agent decisions, correction by compensating delta rather than rewrite, and where the boundary sits between session state and your own transactional store.
## One write path, deliberately ADK's session model is event-sourced in spirit: the durable truth is the ordered list of `Event` objects, and state is what you get by applying their deltas in order. The only API that advances that truth is `append_event(session, event)` on the session service. It does two things atomically from the caller's point of view — appends the event to `session.events`, and applies `event.actions.state_delta` to state. That design has a direct consequence: **there is no `save_state()`**. Nothing scans your `Session` object for changes. If you fetch a session and write `session.state["count"] = 1`, you have edited a Python dict that the service is not watching. ## Why the bug hides in development With `InMemorySessionService`, the in-process store and the object you were handed can end up referencing the same structures, so a direct mutation appears to work. Move to `DatabaseSessionService` and the same code silently stops persisting: the next `get_session` rehydrates from rows built out of the event log, and your mutation was never in an event. This is one of the most common "it worked locally" reports in ADK apps, and the fix is never a flush call — it is to route the change through a delta. ## What EventActions carries `EventActions` is the side-effect channel riding on each event. `state_delta` is a dict of the keys this event changes. Alongside it sit `artifact_delta` for artifact saves and control signals such as `transfer_to_agent`, `escalate` and `skip_summarization`. Putting side effects on the event rather than in hidden mutations means every change is visible in the same stream a UI or a tracer is already consuming — you can see *when* the agent set a flag, in order, relative to what it said. Prefix rules are applied at this point, not at read time. Keys under `app:` and `user:` in the delta are routed to their shared scope; keys under `temp:` are dropped and never reach storage. So the delta is also where the scoping contract is enforced. ## How you actually write state in agent code You rarely construct `EventActions` by hand. Inside a tool or a callback, ADK hands you a context object whose `state` behaves like a dict and records what you changed; the framework attaches those changes as the `state_delta` of the event that turn emits. Writing `context.state["order_id"] = "A-1001"` is therefore both natural to read and correctly committed. Hand-building an event is reserved for orchestration code outside a turn — seeding state after an external webhook, or correcting state administratively — and there you construct `Event(author=..., actions=EventActions(state_delta={...}))` and append it yourself. Note that even a state-only event is a real entry in history; there is no invisible write. ## Consequences worth reasoning about **Ordering and concurrency.** Because state is derived from an append-only log, two turns running concurrently against the same session both append, and last-delta-wins for a contested key. If two workers can drive one session, you need a lock or a session-per-worker design; there is no compare-and-swap in the state API. **Auditability.** Any state value can be traced to the event that set it, and the author of that event. This is genuinely useful when an agent has "decided" something and a human needs to know why and when. **Cost.** Every committed change is a write to the session store. Chatty state updates on a database-backed service are real IO on every turn, and large values are re-loaded with the session forever after. That is the practical argument for `temp:` on anything bulky and for keeping state small and key-like, with the real payload in an artifact or your own database. **Replay.** Because the history is the source, deleting or rewriting past events is not a supported way to change state; you append a corrective delta instead. Treat the log as append-only in your own tooling too. ## The interview-ready summary State in ADK is not a mutable object you save — it is a projection of a delta stream. The write path is `append_event`, the payload is `EventActions.state_delta`, and the ergonomic front door is the context's `state` inside a tool or callback. Direct mutation of a fetched `Session` is a no-op against anything durable, and the reason it sometimes appears to work is precisely the reason it is dangerous.
- Two workers drive the same ADK session concurrently and both set the same state key. What happens?Both append events, and the last delta applied wins for that key — there is no compare-and-swap or optimistic-concurrency check in the state API. History will show both writes with their authors and order, so the outcome is explicable but not prevented. If a session can be driven from two places, serialise it with a lock or partition work so one session has one writer.
- How do you seed state from an external event, such as a webhook, outside any turn?Construct an `Event` whose `actions` carry the `state_delta` you want and append it with `session_service.append_event(session, event)`. It becomes a real, attributable entry in history rather than a hidden write — which is what you want when explaining later why the agent believed an order was paid. Use an author name that identifies the system, not an agent.
- What is the cost argument against writing state on every step of a long tool chain?Each committed delta is a write to the session store and every persisted key is re-loaded with the session on every subsequent turn, so chatty updates cost IO now and context weight forever. Keep durable state small and key-like — ids, flags, short preferences — and use `temp:` for intermediates or push the payload into an artifact and store only its filename.
saying these in an interview costs you the question
- Looking for a save_state or flush method on the session
- Assuming in-place edits to session.state are persisted
- Believing temp: keys are stored and later expired
- Thinking state changes are invisible to the event stream
- Expecting atomic compare-and-swap on a state key