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.