A Session is ADK’s unit of a conversation: one continuous, stateful thread between a user and an agent, captured as a single object the runtime can create, load, append to, and hand back. It is deliberately small and concrete — an identity, an ordered list of everything that has happened, and a bag of working state — and everything else in ADK’s memory story is built on top of it. This piece is about the object and the service that manages it: what a Session actually holds, how the SessionService interface lets you create and retrieve one without caring where it is stored, how a session accumulates its history one event at a time, how sessions are named and scoped so the right thread comes back to the right user, and how a session moves from an empty shell at creation to a fully rehydrated conversation on resumption. The mechanics of the state dictionary itself, and the trade-offs between storage backends, are their own topics — here the focus is the Session as the thing those topics manage.

The Session is the unit of a conversation

Before any backend, any scope prefix, any resumption logic, there is one object you have to be clear about: the Session. In ADK a session is a single conversation thread — one user talking to one app over some span of turns — represented as a plain data object the runtime passes around. It is not a connection, not a process, and not a request; those come and go while the session persists. When a user opens a chat and sends three messages, gets two tool-backed answers, and comes back an hour later to ask a follow-up, all of that belongs to one session.

Framing it this way matters because it separates the conversation from the machinery serving it. A Runner drives the agent loop, a model produces tokens, tools perform side effects — but the durable record of what the conversation is lives in the session, not in any of those. That separation is what lets a different process pick the conversation up later, and it is why nearly every ADK memory concept — state, events, resumption, long-term memory — is described in relation to the session. Get the Session object right in your head and the rest of the model falls into place.

Advertisement

Anatomy of a Session object

A Session is a small, well-defined record. In ADK it carries a handful of fields, and knowing exactly what each one is removes most of the confusion people have about ‘where does the agent’s memory live?’

FieldWhat it is
idThe unique identifier of this conversation thread
app_nameWhich agent application the session belongs to
user_idWhich user the conversation is with
eventsThe ordered, append-only list of everything that happened
stateA dictionary of current working values the agent reads and writes
last_update_timeTimestamp of the most recent change to the session

Two of these fields do most of the work and are worth holding apart in your mind. The events list is the history — the immutable, chronological account of every message, model response, and tool call. The state dict is the working set — the current values the agent needs right now, like the order being discussed or the step in a workflow. History is what happened; state is where things stand. The other four fields — the identity triple plus the timestamp — exist to name the session, route it to the right user, and order it in time. That is the whole object.

Identity: the app_name / user_id / session_id triple

A session is not addressed by its id alone. ADK identifies a session by a triple: (app_name, user_id, session_id). All three are required to look one up, and that is a deliberate design choice, not bureaucracy. The app_name partitions sessions by application, so two different agents can share a store without colliding. The user_id scopes the conversation to a person, which is the boundary that keeps one user’s thread from ever being handed to another. The session_id picks the specific conversation among the possibly many that one user has had with that app.

The practical consequence is that the triple is a security boundary as much as an addressing scheme. Because get_session takes all three, a request that presents a valid session_id but the wrong user_id does not silently fetch someone else’s conversation — the lookup is scoped by construction. Treat the triple as the primary key of a conversation: generate session_id values that are hard to guess, always carry the authenticated user_id from your auth layer rather than trusting the client, and never reuse one user’s session_id under another user’s identity. The scoping of state values within a session — the user: and app: prefixes — is a separate mechanism covered in the state-management article; this is scoping of the session itself.

Events: how a Session accumulates history

The events list is where a session grows. Every meaningful thing that happens in a turn becomes an Event appended to the end of the list: the user’s message, the model’s response, a function (tool) call, a function response, and any state changes those carried. The list is ordered and effectively append-only — new events go on the end, existing ones are not rewritten. That single discipline is what makes a session reconstructable: replay the events in order and you have rebuilt the exact conversation.

Each Event is itself a small structured record. It has an author (who produced it — the user, or a named agent), a content payload (the message or tool interaction), an actions object that can carry a state_delta (the changes this event makes to session.state), a timestamp, and an invocation_id tying it to the run that produced it. This is why the events list is more than a chat transcript: it is the ledger that also records how state evolved, event by event. When people say ADK sessions are ‘event sourced,’ this is what they mean — the history is the source of truth, and the current state is the accumulation of every event’s delta applied in order.

The SessionService interface

You rarely touch a Session’s storage directly. Instead you go through a SessionService — the interface (an implementation of ADK’s BaseSessionService) that owns the lifecycle of sessions and hides where they actually live. Its surface is small and CRUD-shaped, which is the point: a handful of operations cover everything the runtime needs.

OperationWhat it does
create_sessionMake a new session (optionally with initial state and a chosen id)
get_sessionLoad an existing session by its identity triple
list_sessionsEnumerate a user’s sessions for an app
delete_sessionRemove a session and its history
append_eventAdd an event to a session and apply its state changes

The value of pinning this interface down is decoupling. Your agent code, and the Runner that drives it, are written against these methods — not against a database, a file, or a cloud API. Swap an in-memory implementation for a database-backed or a managed one and none of your agent logic changes, because it only ever asked the service to create, get, list, delete, and append. The comparison of those concrete backends is its own article; what matters here is that they are interchangeable precisely because they all satisfy this same small contract.

Creating a session

Every conversation starts with create_session. You choose a service implementation, then ask it for a new session under a given app and user. You may hand it an initial state dict and an explicit session_id; if you omit the id, the service generates one. In current ADK the service methods are asynchronous, so you await them.

from google.adk.sessions import InMemorySessionService

session_service = InMemorySessionService()

# Create a fresh conversation for this user, optionally seeding state.
session = await session_service.create_session(
    app_name="support_bot",
    user_id="user-42",
    session_id="conv-2026-07-23-abc",   # omit to auto-generate
    state={"cart_items": 0, "tier": "free"},
)

print(session.id)                # conv-2026-07-23-abc
print(session.events)            # [] — empty at birth
print(session.state["tier"])     # free

What you get back is a Session object with its identity set, an empty events list, the initial state you provided (or an empty dict), and a fresh last_update_time. That is the shell a conversation grows into. Note that creation is where any seed state belongs — a system prompt variable, a default preference, the user’s tier — because it is present before the first turn runs. In most real apps you do not call this by hand on every message; the Runner is given the service and creates or reuses the session for you.

Retrieving and resuming a session

The complement of creation is get_session: given the identity triple, hand back the full session — its accumulated events and its current state — so a new turn continues where the last one left off. This is the load half of the lifecycle, and it is what makes a conversation survive across disconnects and process restarts.

# Later — a new request, possibly on a different runner process.
session = await session_service.get_session(
    app_name="support_bot",
    user_id="user-42",
    session_id="conv-2026-07-23-abc",
)

if session is None:
    # No such conversation for this user — start a new one.
    session = await session_service.create_session(
        app_name="support_bot", user_id="user-42",
        session_id="conv-2026-07-23-abc",
    )

# Full history and state are back, ready for the next turn.
print(len(session.events))       # every prior event, in order
print(session.state.get("cart_items"))

Two details matter. First, get_session returns None when nothing matches the triple, so the honest pattern is get-or-create: try to load, and fall back to create. Second, the object you get back is a fully rehydrated conversation — the events replayed and the state folded — not a stub. That is resumption at the object level: the mechanism of durably storing and reconstructing across crashes is covered in the resumption article, but the interface to it is simply this call. Some services also accept a config to bound what you load (for example, only the most recent events), which keeps rehydration cheap for very long conversations.

Advertisement

append_event: the one write path

If create and get are how sessions enter and leave memory, append_event is how they change. It is the single, deliberate write path for an active conversation, and it does two things at once: it adds the event to the session’s events list, and it applies that event’s state_delta to session.state. History and state advance together, atomically, through one call.

from google.adk.events import Event, EventActions
from google.genai import types

event = Event(
    author="user",
    content=types.Content(role="user",
        parts=[types.Part(text="Add 2 widgets to my cart")]),
    actions=EventActions(state_delta={"cart_items": 2}),
)

await session_service.append_event(session, event)
# session.events now ends with this event;
# session.state["cart_items"] is now 2;
# session.last_update_time has advanced.

Routing every mutation through append_event is what keeps the two halves of a session consistent. You do not poke session.state in one place and shove a message into session.events in another and hope they agree; the event carries both, and applying the event is the commit. In normal use the Runner constructs and appends these events as the agent runs — you author tools and state deltas, and the framework calls append_event — but understanding that this is the sole write path demystifies how state and history stay in lockstep.

last_update_time and ordering

The quietest field on a Session is last_update_time, and it earns its place. It records when the session last changed — advanced on every append_event — and it turns the session from a bag of data into something you can reason about temporally. That has several concrete uses. It lets a UI sort a user’s conversations by recency so the thread they were just in floats to the top. It lets you expire or archive sessions that have gone quiet for long enough. And it gives you a cheap freshness check: is the session I hold in memory still the latest, or did another turn land since I loaded it?

That last use points at a real hazard. A session loaded into one process is a snapshot; if two turns for the same conversation are processed concurrently, each can hold a stale copy and clobber the other’s appends. The last_update_time is the signal that lets a service detect and reject a write against an out-of-date view. The append-only event model helps here too — because writes are appends keyed to a position rather than whole-object overwrites — but the practical guidance is simple: do not run two turns of the same session in parallel, and let the service, not your code, be the arbiter of ordering.

The lifecycle of a session

Put the operations in order and a session’s life is a clean arc. It is worth seeing end to end, because each stage maps to exactly one part of the interface.

StageWhat happensInterface
CreationEmpty shell with identity and any seed statecreate_session
Active turnsEvents append; state and history advance togetherappend_event
InterruptionConnection or process ends; the durable copy remains(store persists)
ResumptionReload by triple; full history and state returnget_session
RetirementConversation ends; session removed or archiveddelete_session

The shape to notice is that creation and retirement bracket the session, append_event is the whole middle, and resumption is not a special mode but simply a get_session that happens after an interruption. There is no distinct ‘save’ step in this picture: because each append advances the durable record, the session is always at its latest committed point, and a resume just reads that point back. That is the payoff of the append-only design — the lifecycle has no gap where progress is held only in volatile memory waiting to be flushed.

Session, State, and Events: three concepts, one object

Because they live so close together, it is easy to blur the Session, its state, and its events into one idea. Keeping them distinct is the single most useful mental move for working with ADK memory. The Session is the container and the identity — the named conversation. The events are its history — the immutable, ordered ledger of what happened. The state is its working set — the mutable current view the agent reads and writes each turn.

They answer different questions and change at different rates. Ask ‘what happened, and in what order?’ and you are asking about events, which only ever grow. Ask ‘where do things stand right now?’ and you are asking about state, which is the fold of every event’s delta. Ask ‘whose conversation is this and how do I get it back?’ and you are asking about the session and its identity triple. The internals of the state dictionary — scope prefixes, deltas, how instruction templates read it — are covered in the state-management article, and they are genuinely a separate topic. Here the point is only that state and events are two faces of one Session object, and the object is what a SessionService manages.

The service as an abstraction over backends

The reason ADK bothers to define SessionService as an interface — rather than just giving you a session and a database — is that the same five operations can be satisfied by very different storage. An in-memory implementation keeps sessions in a process dictionary: perfect for tests and local development, and gone the moment the process exits. A database-backed implementation persists them to SQL so they survive restarts and can be shared across a fleet of runners. A managed implementation delegates to a cloud service that handles durability and scale for you.

What every one of them shares is this article’s subject: they all vend the same Session object and honor the same create / get / list / delete / append contract. That is exactly why you can prototype against the in-memory service and move to a durable one for production without rewriting a line of agent logic — the Runner and your tools only ever spoke the interface. Choosing among those backends — the durability, latency, and operational trade-offs of in-memory versus database versus managed — is its own decision, treated in the session-storage article. The takeaway for the Session object is that its portability across all of them is not an accident; it is the whole reason the service interface exists.

Practical guidance and gotchas

A few habits keep sessions from biting you. Always get-or-create. A missing session returns None, not an error; code that assumes a load always succeeds will crash on the first genuinely new conversation. Own your session_id scheme. If you let clients pick ids, make them unguessable and always pair them with an authenticated user_id — the triple is your access boundary. Do not mutate session.state as a side channel. Route changes through events so history and state stay consistent; a value poked directly into the dict has no event backing it and will not survive a reload the way an appended delta does.

Do not treat a loaded session as live-synchronized. It is a snapshot as of last_update_time; another turn can move the real session underneath you, so avoid concurrent turns on one conversation. Mind session growth. The events list only grows, so very long conversations get expensive to load and to feed to the model — bound what you rehydrate, and lean on long-term memory rather than an ever-longer session for facts that should outlive the thread. None of these require exotic knowledge; they fall straight out of understanding that a Session is an identity plus an append-only history plus a working-set state, managed through one small service.

A Session is ADK’s unit of a conversation: a small object holding an identity (id, app_name, user_id), an ordered append-only events list, a working-set state dict, and a last_update_time. You never manage it directly; you go through a SessionService whose small contract — create_session, get_session, list_sessions, delete_session, append_event — lets you create, load, and grow a conversation without knowing where it is stored. History accumulates one event at a time through append_event, which advances both events and state together; the identity triple names and scopes the thread; and resumption is nothing more exotic than a get_session that returns the fully rehydrated conversation. Keep the Session, its events, and its state distinct in your mind, route every change through an event, and the storage backend becomes a swappable detail rather than a rewrite.