GEMINI LABJP
MEMORY — Memory Profiles in Memory Bank are now GA. A fixed schema means agents reach evolving information without an expensive search mid-sessionSCOPE — Profiles are isolated by the scope you pass at ingest, and each schema-and-scope pair keeps a single profile as its source of truthINGEST — The IngestEvents API reached GA, bringing smoother event streaming, memory revision controls, and metadata supportAUDIO — gemini-3.1-flash-tts-preview now streams through streamGenerateContent, cutting the wait before the first audio arrivesCLASSROOM — From August 10, Gemini in Classroom opens to K-12 and higher education students of all ages who already have accessSUNSET — Shutdown dates are close: image generation models on August 17, the Grok 4.1 family on the 20th, and gemini-robotics-er-1.6-preview on the 31stMEMORY — Memory Profiles in Memory Bank are now GA. A fixed schema means agents reach evolving information without an expensive search mid-sessionSCOPE — Profiles are isolated by the scope you pass at ingest, and each schema-and-scope pair keeps a single profile as its source of truthINGEST — The IngestEvents API reached GA, bringing smoother event streaming, memory revision controls, and metadata supportAUDIO — gemini-3.1-flash-tts-preview now streams through streamGenerateContent, cutting the wait before the first audio arrivesCLASSROOM — From August 10, Gemini in Classroom opens to K-12 and higher education students of all ages who already have accessSUNSET — Shutdown dates are close: image generation models on August 17, the Grok 4.1 family on the 20th, and gemini-robotics-er-1.6-preview on the 31st
Articles/Advanced
Advanced/2026-08-09Advanced

The Memory Wasn't Lost — It Was Written to a Different Profile

A Memory Bank profile is identified by the pair of schema and scope. When call sites build that scope slightly differently, extra profiles appear with no error at all. Here are the measured numbers for how badly it fragments, and how three candidate fixes actually performed.

Gemini API208Memory Bank2Memory profiles2Scope designMulti-tenancyIndie development

Premium Article

I was looking at the morning rollup when I stopped scrolling.

Two apps share a support pipeline in my setup: a wallpaper app and a companion relaxation app. Both were feeding Memory Bank profiles so that downstream handlers could see a user's preferences and subscription state. That morning, the number of ingestion calls was about 4.8 times the number of distinct users who had actually interacted that day.

Not a single error. Every response was a 200. But one profile per user should have been enough, and the arithmetic said roughly five were being created instead.

It took me a while to see it. The memory wasn't disappearing. It was being written to a slightly different address every time.

A Memory Bank profile is held as a single source of truth per combination of schema and scope. That means scope is not a label pointing at "whose data this is" — it is the composite key that decides where the data lives. If the key drifts, what sits there is a different profile, and an empty profile comes back as a perfectly valid response. A call site that wrote {"user_id": 123} one day and {"user_id": "123"} the next was writing to two different places. Until I saw that, I had been trying to solve the wrong problem entirely: "why doesn't the memory stick?"

I didn't want to fix this by intuition, so I built a small harness. It uses no API key. It reproduces only the published semantics — one profile per (schema, scope) pair — locally. Every number below comes from actually running that harness; none of it is estimated. And the results very nearly inverted my ranking of the "obviously correct" fixes.

Scope Isn't an Identifier — It's the Address

First, the model. I'm not guessing at Memory Bank's internals; I'm copying only the contract, which is that a schema-and-scope pair maps to exactly one profile.

import json
 
SCHEMA = "user_pref_v1"
 
class ProfileStore:
    """A minimal stand-in for Memory profiles.
    Holds one profile per (schema, scope) pair. No API calls —
    this reproduces only how the key is derived."""
 
    def __init__(self, normalizer=None):
        self.data = {}
        # Swappable so we can experiment with normalization strategies
        self.norm = normalizer or (lambda s: json.dumps(s))
        self.writes = 0
 
    def key(self, scope):
        return (SCHEMA, self.norm(scope))
 
    def get(self, scope):
        # A miss returns None. That's "nothing here yet", not "you asked wrong"
        return self.data.get(self.key(scope))
 
    def put(self, scope, profile):
        self.writes += 1
        self.data[self.key(scope)] = profile

The important detail is that get() never raises. Query a scope that doesn't exist and you get emptiness, not an error. There is no built-in signal that you have addressed the wrong place. That asymmetry is what motivates the alarm I build later in this article.

One structural note about my setup: user IDs are assigned per app. User 42 in the wallpaper app and user 42 in the relaxation app are different people. So app_id genuinely belongs in the scope — it isn't decoration, it's correctness. That fact turns out to matter a great deal.

Six Ways the Call Sites Wrote It, and How Much They Fragmented

Going back through the codebase, scope construction had scattered into six shapes. Every one of them looked reasonable at the moment it was written.

VariantHow it was writtenWhat the author meant
ok{"app_id": app, "user_id": str(uid)}The intended form
int{"app_id": app, "user_id": uid}Passed the integer straight from the DB
extra_tier{"tier": tier, "app_id": app, "user_id": str(uid)}Wanted to separate by plan
extra_device{"app_id": app, "user_id": str(uid), "device_id": device}Wanted stricter isolation
extra_locale{"app_id": app, "user_id": str(uid), "locale": locale}Anticipated language-specific replies
pad{"app_id": app, "user_id": " " + str(uid)}A concatenation artifact nobody intended

I generated 4,000 turns across 200 users and 2 apps using that distribution, then measured hit behavior with a plain json.dumps key.

import random, statistics
from collections import defaultdict
 
APPS = ["wallpaper", "relax"]
 
def gen(users=200, turns=4000, seed=20260809):
    """Generate traffic that includes realistic call-site drift.
    Weights match the observed distribution in the real codebase."""
    rnd = random.Random(seed)
    out = []
    for _ in range(turns):
        app = rnd.choice(APPS)
        u = rnd.randrange(users)
        ctx = {
            "tier": rnd.choice(["free", "pro"]),
            "device": f"d{rnd.randrange(3)}",
            "locale": rnd.choice(["ja-JP", "en-US"]),
        }
        kind = rnd.choices(
            ["ok", "int", "extra_tier", "extra_device", "extra_locale", "pad"],
            [0.46, 0.14, 0.12, 0.12, 0.10, 0.06],
        )[0]
        base = {"app_id": app, "user_id": str(u)}
        s = {
            "ok": base,
            "int": {"app_id": app, "user_id": u},
            "extra_tier": {"tier": ctx["tier"], "app_id": app, "user_id": str(u)},
            "extra_device": {"app_id": app, "user_id": str(u), "device_id": ctx["device"]},
            "extra_locale": {"app_id": app, "user_id": str(u), "locale": ctx["locale"]},
            "pad": {"app_id": app, "user_id": " " + str(u)},
        }[kind]
        out.append((app, u, kind, s, ctx["tier"]))
    return out
 
 
def run(calls, norm):
    store = {}
    hits = miss = 0
    keys_per_tenant = defaultdict(set)
    by_kind = defaultdict(lambda: [0, 0])   # kind -> [hit, miss]
 
    for app, u, kind, s, tier in calls:
        k = (SCHEMA, norm(s))
        keys_per_tenant[(app, u)].add(k[1])
        p = store.get(k)
        if p is None:
            miss += 1
            by_kind[kind][1] += 1
            store[k] = {"owner": (app, u), "billing_plan": tier}
        else:
            hits += 1
            by_kind[kind][0] += 1
 
    n = hits + miss
    return {
        "hit": 100 * hits / n,
        "profiles": len(store),
        "frag": statistics.mean(len(v) for v in keys_per_tenant.values()),
        "writes": miss,
        "by_kind": dict(by_kind),
    }

With a plain json.dumps key:

MetricValue
Profile hit rate52.1%
Profiles created1,915
Profiles per tenant4.79
Ingestion calls (misses)1,915

The 4.8x I'd seen in the morning rollup reproduced almost exactly. That's the point where this stopped feeling like wasted spend and started feeling like something worse: each user's history had been sliced into five disconnected pieces.

Breaking it down by variant ranks the culprits.

VariantHit ratehit / miss
ok79.2%1,512 / 397
int44.3%228 / 287
extra_tier25.3%116 / 342
pad24.8%65 / 197
extra_locale22.8%87 / 294
extra_device16.2%77 / 398

extra_device finishing last stung. That was the key I added because I wanted stricter isolation. The single line I wrote to be more careful was doing more damage than the sloppy whitespace bug. The mechanism is obvious in hindsight: device_id changes when the same person picks up a different phone. The moment it enters the scope, it becomes the granularity of separation itself. Strictness here can only ever reduce who gets to share a memory — including the past version of the same user.

Note also that ok only reached 79.2%. Correctly written call sites still miss when something wrote to a different address just before them. One drifting site drags down the score of every correct site. Because of that spillover, the reassuring feeling of "well, most of our code does it right" turned out to be worth nothing.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Six drift variants across call sites dropped the profile hit rate to 52.1% and produced 4.79 profiles per tenant — with zero errors raised
The widely recommended sort_keys canonicalization moved the number by 0.0 points; a key allowlist alone restored 90.0%, exactly the theoretical ceiling — full comparison harness included
The read-side fallback scored the best hit rate while returning 1,843 cross-tenant reads over 4,000 turns, plus the ceiling alarm and AST lint that catch it beforehand
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

API / SDK2026-08-03
Measuring Update Policies for Memory Profiles: The Guard That Cost Me 16 Points of Accuracy
Memory profiles went GA in Memory Bank, making structured memory available to downstream code. I built three update policies and compared them under identical conditions. The one that looked obviously correct turned out to be the worst. Full harness code and the path to per-field TTLs.
Advanced2026-08-06
The Day the Knowledge Cutoff Moved Forward, the Stale Part Was My System Instruction
When a model's knowledge cutoff advances, the thing that goes stale is not the model — it is the dated assertions in your system instruction. Here is why only the lines written between the two cutoffs flip from helpful to contradictory, plus a working audit script and its measured results.
Advanced2026-07-28
When Version Numbers Stopped Meaning Generations: Rebuilding Cost Attribution That Parsed Model IDs
A regex that derived generation and tier from Gemini model IDs broke quietly once Flash reached 3.6 while the top Pro stayed at 3.5. Here are the runnable probes, the attribution gap between regex-derived and registry-joined rollups, and the redesign that treats model IDs as opaque keys.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →