Every ADK agent talks to a SessionService, but which implementation sits behind that interface is the single decision that decides whether your agent survives a restart, scales past one replica, and keeps a conversation coherent across days. The Session is the concept — the ordered event log and the state fold for one conversation thread. Session storage is the orthogonal question of where that log physically lives: in the process’s heap, in a SQL database you run, or in a managed store on Agent Engine. ADK ships three backends behind one API — InMemorySessionService, DatabaseSessionService, and VertexAiSessionService — and they differ only in durability, scale, latency, cost, and how much of the operational burden you carry. This piece walks all three: what they persist, how to wire each into a Runner, the trade-offs in a single table, how to migrate from the in-memory default to a real database without rewriting your agent, and how session storage relates to the separate MemoryService.
The backend is the production decision
The agent code you write in a notebook and the agent code you run in production are, ideally, the same code. What changes between them is almost entirely which service objects you hand the Runner. That is the whole point of ADK putting sessions behind an interface: create_session, get_session, append_event, list_sessions, and delete_session mean the same thing regardless of where the bytes land. Your agent, tools, and callbacks never know the difference.
Because the contract is fixed, the choice of backend becomes a pure infrastructure decision rather than an application rewrite. That is liberating and dangerous in equal measure: liberating because you can develop against the fastest, zero-setup option and swap in a durable one for launch; dangerous because the default is the one that cannot ship, and nothing in your code will complain when you deploy it. A session backend has no compile-time signal that it is wrong for production — it fails silently the first time a replica restarts or a second replica comes online. Treating the backend as an explicit, reviewed choice — not an inherited default — is the discipline this article is really about.
What a SessionService actually persists
Before comparing backends, be precise about what they store, because it explains why the durable ones look the way they do. A session is not a mutable blob of ‘the current state.’ It is an append-only list of events — user messages, model responses, function calls and their results, and state_delta actions — each immutable, ordered, and authored. The state an agent reads is the fold of those deltas: a derived view, materialized for cheap reads, never the source of truth.
So a session backend has to persist four things: the session record itself (keyed by app_name, user_id, session_id, plus timestamps), the ordered events, and the state maps — and state is partitioned by scope. Session-scoped keys live with the session; user: keys are shared across all of one user’s sessions; app: keys are shared across the whole app; temp: keys are turn-local and never persist at all. A durable backend therefore needs somewhere to put session state, user-scoped state, and app-scoped state separately, and it must apply each event’s deltas transactionally so a crash mid-turn cannot leave a half-written fold.
InMemorySessionService — the dev and test default
InMemorySessionService keeps everything in Python dictionaries inside the running process. It is the default in adk web and adk run, it needs zero configuration, and it is genuinely the right choice for its job: local development, unit and integration tests, notebooks, and throwaway demos. Reads and writes are just hash-map lookups, so it adds effectively no latency, and there is no connection to manage, no schema to migrate, nothing to provision.
Its limits are the mirror image of its virtues, and they are absolute rather than gradual. It does not survive a restart: the moment the process exits — a crash, a deploy, a Cloud Run instance scaling to zero — every session is gone. It does not survive horizontal scaling: two replicas hold two disjoint dictionaries, so a user whose next request is routed to a different instance appears to have lost their entire conversation unless you pin them with sticky sessions, which defeats the point of stateless scaling. The failure is invisible in a single-process test and catastrophic in production. Use it precisely because it is ephemeral, and never one step further — the whole job of the other two backends is to remove exactly this ceiling.
DatabaseSessionService — durable and self-managed
DatabaseSessionService persists sessions to a relational database through SQLAlchemy, which means it speaks to any engine SQLAlchemy supports: PostgreSQL and MySQL for real deployments, SQLite for a local file-backed store that survives restarts without a server. You construct it with a standard database URL, and ADK maps the session model onto tables for you.
from google.adk.sessions import DatabaseSessionService
# Postgres in production (e.g. Cloud SQL)
db_url = "postgresql+psycopg2://user:pass@10.0.0.3:5432/agents"
session_service = DatabaseSessionService(db_url=db_url)
# ...or a local file for a durable single-machine setup
session_service = DatabaseSessionService(db_url="sqlite:///./sessions.db")This is the workhorse for self-hosted production — typically an ADK API server on Cloud Run, GKE, or your own VMs — where you want durability and horizontal scale but also want to own your data, run inside your VPC, and stay off any one vendor’s managed runtime. Because every replica reads and writes the same database, any instance can serve any session: the replicas become stateless and the database becomes the single source of truth. The price is that you now operate a database — sizing, backups, failover, connection limits, and migrations are yours.
The persisted schema and how state is stored
On first use against an empty database, DatabaseSessionService creates its tables automatically. Conceptually you get a sessions table (the app/user/session identity plus timestamps), an events table (the append-only log, one row per event, ordered and foreign-keyed to its session), and separate state tables for the scopes that outlive a single session — app-scoped state and user-scoped state — so a user: fact written in one conversation is visible in the next.
State is stored as structured JSON columns rather than one row per key, so the fold for a session is loaded and written as a unit. The design has direct consequences you should plan for. First, values you put in state must be JSON-serializable — primitives, lists, and dicts, not arbitrary Python objects. Second, large blobs do not belong in state: a 30-page PDF or an image should go to the artifact service and be referenced by name, keeping the session rows small and fast to load every turn. Third, because events are append-only, a session’s row count grows with conversation length — which is exactly why long conversations need windowing and compaction upstream, and why a session TTL and deletion policy belong in your operational plan from day one.
Connection management for the database backend
The moment you put a database behind your agent, its connection pool becomes a first-class scaling constraint. Each ADK replica holds its own SQLAlchemy engine and pool; a database like Postgres caps total connections, so replicas × pool_size must stay under that ceiling. Cloud Run scaling to 60 instances against a small Cloud SQL tier is a classic way to exhaust connections and start refusing turns — a failure that looks like the agent hanging, not like a database error.
Two levers keep this healthy. Tune the pool explicitly — a modest pool_size with a sensible max_overflow and pool_recycle so idle connections are refreshed before the server drops them — and put a connection pooler (PgBouncer, or Cloud SQL’s built-in pooling) in front so hundreds of thin app-side connections multiplex onto a bounded set of real ones. The broader principle: session reads and writes now sit on the hot path of every single turn, so the database’s latency and availability are your agent’s latency and availability. Co-locate the database with the compute, keep the pool warm against cold starts, and treat the pool ceiling as a capacity number you plan, not a limit you discover in an incident.
VertexAiSessionService — fully managed on Agent Engine
VertexAiSessionService hands the whole problem to Google. Sessions live in a managed store operated as part of Vertex AI Agent Engine; there is no database for you to provision, scale, back up, or pool. You point the service at a project and location, and — when you deploy onto Agent Engine — the app_name is the Reasoning Engine resource that owns those sessions.
from google.adk.sessions import VertexAiSessionService
session_service = VertexAiSessionService(
project="my-gcp-project",
location="us-central1",
)
# On Agent Engine, app_name is the Reasoning Engine resource id,
# and durability, scaling, and backups are the platform's job.This is the lowest-operational-burden option and the natural default when your differentiation is the agent, not the infrastructure. Durability, horizontal scale, and identity integration come built in, and the same managed environment offers a companion managed memory service, so session and long-term memory live in one integrated platform. The trade is the usual managed-service trade: less to run, but less to customize, a dependency on Agent Engine and its regions, and a pricing model set by the platform rather than by the database tier you would have sized yourself.
The three backends compared
Put side by side, the three occupy a clean spectrum from ‘fast and disposable’ to ‘durable and hands-off,’ with the self-managed database sitting in the middle as the maximum-control option.
| Dimension | InMemory | Database (SQLAlchemy) | VertexAi |
|---|---|---|---|
| Durability | None — lost on restart | Durable (your DB’s guarantees) | Durable, managed |
| Horizontal scale | No — per-process only | Yes — shared DB, stateless replicas | Yes — platform-managed |
| Operational burden | Zero | High — you run the database | Low — Google runs it |
| Latency | Negligible (in-heap) | Network + query per turn | Managed API call per turn |
| Cost | Free (RAM) | DB instance + ops time | Agent Engine / platform pricing |
| Best for | Dev, tests, demos | Self-hosted prod, data ownership, VPC | Managed prod on Agent Engine |
Read the table as a decision, not a scorecard. In-memory is disqualified from production by the first two rows alone. Between the other two the question is ownership: choose the database backend when you need to run in your own environment, keep data inside your VPC, or standardize on containers; choose the managed service when you would rather not operate a database and Agent Engine fits your stack. There is no ‘fastest’ winner — there is the one whose operational shape matches your team.
Wiring each backend into a Runner
The payoff of the shared interface is that swapping backends is a one-line change at construction time. The Runner takes a session_service (and optionally a memory_service and artifact_service); the agent it drives is identical in every case.
from google.adk.runners import Runner
from google.adk.sessions import (
InMemorySessionService,
DatabaseSessionService,
VertexAiSessionService,
)
def make_session_service(env: str):
if env == "dev":
return InMemorySessionService()
if env == "self_hosted":
return DatabaseSessionService(db_url="postgresql+psycopg2://user:pass@db/agents")
return VertexAiSessionService(project="my-proj", location="us-central1")
runner = Runner(
agent=root_agent, # unchanged across every backend
app_name="support-agent",
session_service=make_session_service(ENV),
)Because only the factory function knows which backend is live, your agent logic, tools, and callbacks are written once and tested once. The eval suite that passed against InMemorySessionService in CI is exercising the same behavior that will run against Postgres or Vertex in production — the persistence layer changed, the semantics did not. Drive the selection from an environment variable or config so promotion from dev to prod is a deploy-time setting, never a code edit.
Migrating from in-memory to a database in production
The most common ADK migration is the one from the notebook default to a real store, and done right it is nearly mechanical. Point the factory at DatabaseSessionService (or VertexAiSessionService), provision the database, and let the service create its tables on first run. Because the interface is unchanged, no agent, tool, or callback code moves — which is exactly the property that makes the switch safe.
The pitfalls are operational, not logical. First, in-memory sessions are not portable: there is no export from the old dictionaries into the new database, so migrate before you have conversations worth keeping, and expect the cutover to start histories fresh. Second, audit your state values now that they must be JSON-serializable and durable — a stray non-serializable object that was tolerated in memory will fail on write. Third, the moment state is shared, correctness assumptions change: two replicas can touch the same user: keys concurrently, so treat cross-session state as genuinely shared data. Finally, stand the database backend up in staging behind your eval suite before promoting it, because this is where connection-pool sizing, serialization edge cases, and TTL policy reveal themselves — cheaply in staging, expensively in production.
How session storage relates to memory
Session storage and the MemoryService are different stores solving different timescales, and conflating them is a recurring design error. A session is one conversation thread: it grows linearly with that conversation and is loaded in full (subject to windowing) on every turn, so it must stay small and fast. Memory is the user’s lifetime knowledge across many sessions: it grows without bound and is searched, never loaded wholesale, via memory tools the agent calls when it needs to recall something.
They are provisioned independently, and the backends do not have to match. You might pair DatabaseSessionService with a vector-backed memory service, or use VertexAiSessionService alongside Agent Engine’s managed memory bank. The typical flow ties them together: a session runs to completion in session storage, then its distillation is ingested into memory so a later, entirely separate session can retrieve it by search. Keeping the two stores distinct is what lets each stay honest — sessions small enough to load per turn, memory large enough to hold a relationship — and it is why ADK exposes session_service and memory_service as separate arguments to the Runner rather than one combined store.
Operational discipline: TTLs, deletion, and migrations
A durable backend converts convenience problems into compliance and cost problems, and the good news is that ADK’s uniform API makes the runbooks tractable. Retention: sessions accumulate forever unless you set a TTL and a deletion policy; decide how long a conversation lives and enforce it, both to cap storage cost and to satisfy data-retention rules. Deletion: because delete_session is part of the interface and user-scoped state is stored separately, a data-deletion request becomes an enumerable job — remove the user’s sessions, their user: keys, their memory entries, and their artifacts.
Two more habits pay off. Schema migrations: auto-created tables are fine on day one, but once you carry real conversation history, treat the session schema like any production schema — version it, back it up, and plan upgrades of the ADK version that might touch it. PII discipline: durable state is durable PII, so keep request-scoped secrets and auth payloads in the non-persisted temp: scope, and be deliberate about which fields are allowed to land in a database row at all. None of this exists with the in-memory backend — which is another way of saying none of it is optional once you are actually in production.
Choosing a backend
Strip the decision to its honest core and it is short. Are you developing, testing, or demoing on one process, where losing state on restart is fine or even desirable? Use InMemorySessionService and enjoy the zero setup. Are you going to production and want to own your data, run inside your own network, or standardize on containers on Cloud Run or GKE? Use DatabaseSessionService on Postgres or MySQL, and budget for operating that database properly — pool, pooler, backups, TTLs, migrations. Are you going to production on Agent Engine and would rather not run a database at all? Use VertexAiSessionService and let the platform carry durability and scale.
The mistake to avoid is drift: shipping the in-memory default because it worked in the demo and nothing forced you to change it. Make the backend an explicit, environment-driven choice reviewed like any other production dependency, keep the agent code identical across all three so promotion is a config change, and remember that the interface is the contract — the Session concept stays the same, and only its storage moves. Get that one decision right and the rest of your agent scales with it.
SessionService interface and three implementations, and choosing between them is the decision that decides whether your agent survives a restart and scales past one replica. InMemorySessionService is fast, zero-config, and strictly for dev, tests, and demos — it loses everything on restart and cannot span replicas. DatabaseSessionService persists to any SQLAlchemy database (Postgres, MySQL, SQLite) for durable, horizontally scalable self-hosted production, at the price of operating that database — schema, pooling, backups, TTLs. VertexAiSessionService hands durability and scale to Agent Engine for the lowest operational burden and the least customization. Because the interface is fixed, your agent code is identical across all three, so the backend should be an explicit, environment-driven choice — never an inherited default — and it stays distinct from the MemoryService, which searches lifetime knowledge while session storage loads one conversation per turn.