◉GEMINI LABJP
●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…●OMNI 1.2 — Gemini Omni 1.2 Flash is generally available (11/5) with up to 15-second video and conversational editing; 1.1 Flash is routed to 1.2 on 12/3●COMPUTER — Computer Use is now generally available in the Interactions and GenerateContent APIs (10/30)●LIVE — Live and TTS preview models shut down as early as 11/17 (10 days left). Move to gemini-3.8-live and the 3.8 TTS models●CLI — Gemini API CLI (gemini-api) is out in beta (10/27): a zero-dependency client for agents and CI●2.5 PRO — gemini-2.5-pro and 2.5-flash are routed automatically on 12/1 (24 days left), to 3.8 Flash and 3.5 Flash-Lite●NEW — Stop Re-Reading Every Crash Report: Gre…
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 API255Memory BankMemory profilesScope designMulti-tenancyIndie development3

✦ 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-10-31
When Computer Use Ends After a Few Steps — Finding the Reason Among Four Exits
When Gemini API Computer Use stops after a few steps with no error, sort the cause into four exits that follow the official loop: no function_call, turn_limit, safety_decision, or a bad function_result. Includes a short script that logs the exit reason in one line.
⬡ Advanced2026-09-24
I Stopped Asking Gemini 'Is This Right?' and Started Hiding My Answer Instead
Ask Gemini to confirm a label you already chose and it tends to agree. Here is why I compared three ways of asking, chose blind re-classification, how my answer still leaked in, and the routing code that sends only disagreements to a human.