Skip to main content

Multi-user app with isolation

A SaaS app, a desktop assistant with several profiles, an internal tool with hundreds of employees — all of them are multi-tenant from day one. iLAB Memory is multi-user out of the box: every facade method takes a user_id and the orchestrator scopes the SQL queries automatically. This recipe shows the pattern, the anti-pattern, and the gotchas.

What you'll need

Python 3.11+

hashlib from the stdlib for stable user IDs.

ilab-memory

pip install ilab-memory

A stable identifier

OIDC subject, hashed email, UUID — anything that won't change for the lifetime of the user.

WAL mode SQLite

Enabled automatically by ILabMemory.from_path. Concurrent reads, serialized writes.

Architecture

┌────────────────┐
│ FastAPI app │
└────────┬───────┘

one ILabMemory
shared instance

┌────────▼────────┐
│ ./memory.db │
│ (WAL mode) │
└─────────────────┘
▲ ▲ ▲
│ │ │
user_id user_id user_id
"u_a" "u_b" "u_c"

One instance, many tenants. The orchestrator filters every read and write by user_id — there is no shared row across users (other than the SQLite metadata).

Implementation

1. Pick a stable user_id

user_id is a string. Use anything stable, opaque, and non-PII when possible. A SHA-256 of the user's email + a per-app salt works well; OIDC sub is better when available.

import hashlib
import os

SALT = os.environ["USER_ID_SALT"] # rotate this only on a coordinated migration

def stable_user_id(email: str) -> str:
return hashlib.sha256(f"{SALT}:{email.lower()}".encode()).hexdigest()[:24]
warning

Do not use the raw email as user_id. Logs, backups, and query plans expose it. A hash is opaque, deterministic, and irreversible without the salt.

2. Share a single ILabMemory instance

Construct it once at startup. The orchestrator is single-threaded but SQLite WAL allows concurrent readers — perfect for FastAPI / desktop apps with light concurrency.

from ilab_memory import ILabMemory
mem = ILabMemory.from_path("./memory.db")
note

For high-write multi-tenant workloads (hundreds of writes per second across many users), consider sharding: one DB file per tenant, looked up at request time. Mentioned as an advanced pattern — see Architecture.

3. Save scoped to a user

Every write takes user_id. The orchestrator stores it as a column on the row — there is no global namespace.

user_id = stable_user_id("alice@example.com")

mem.mem_save(
user_id=user_id,
type="preference",
title="Newsletter opt-in",
content="Subscribed to the weekly digest.",
topic_key=f"user/{user_id}/newsletter",
)

4. Read scoped to the same user

Reads filter by user_id. Memory written under user_id="u_a" is invisible to user_id="u_b" — the SQL WHERE user_id = ? clause is non-negotiable.

results = mem.mem_search(
user_id=user_id,
query="newsletter",
limit=5,
)
# results contain only Alice's observations

mem_get_observation(user_id, observation_id) returns None if the id belongs to a different user — no exception, no leak. This is enforced at the store level.

5. Show per-user stats in the UI

mem_stats(user_id) returns counts (observations, sessions) for one tenant. Perfect for "you have N memories" UI badges or quota dashboards.

stats = mem.mem_stats(user_id=user_id)
print(stats)
# {'observations': 42, 'sessions': 3}

There is no global stats endpoint by design — privacy and tenant isolation come first.

6. Avoid the cross-tenant anti-pattern

Saving two users under the same user_id mixes their memories, breaks search relevance, and can leak in sessions_context. If your app supports multiple identities per human (e.g. work + personal), give each one its own user_id.

# ❌ ANTI-PATTERN — both Alice and her colleague write to "team"
mem.mem_save(user_id="team", ...)

# ✅ Pattern — each human has their own scope
mem.mem_save(user_id=stable_user_id("alice@example.com"), ...)
mem.mem_save(user_id=stable_user_id("bob@example.com"), ...)

Full file

Complete code (copy-paste ready)
from __future__ import annotations

import hashlib
import os
from typing import Mapping

from ilab_memory import ILabMemory

SALT = os.environ.get("USER_ID_SALT", "dev-only-salt")
DB_PATH = os.environ.get("ILAB_MEMORY_DB_PATH", "./memory.db")


def stable_user_id(email: str) -> str:
"""Opaque, deterministic, salted hash. Safe to log."""
return hashlib.sha256(f"{SALT}:{email.lower()}".encode()).hexdigest()[:24]


def onboard(mem: ILabMemory, email: str, prefers_dark_mode: bool) -> str:
user_id = stable_user_id(email)
mem.mem_save(
user_id=user_id,
type="profile",
title="Account created",
content=f"User registered with email <private>{email}</private>.",
topic_key=f"user/{user_id}/profile",
)
mem.mem_save(
user_id=user_id,
type="preference",
title="UI theme",
content=f"prefers_dark_mode={prefers_dark_mode}",
topic_key=f"user/{user_id}/preferences/theme",
)
return user_id


def show_dashboard(mem: ILabMemory, user_id: str) -> Mapping[str, int]:
"""Display counts on the user's settings page."""
return mem.mem_stats(user_id=user_id)


def main() -> None:
with ILabMemory.from_path(DB_PATH) as mem:
alice = onboard(mem, "alice@example.com", prefers_dark_mode=True)
bob = onboard(mem, "bob@example.com", prefers_dark_mode=False)

# Each user only sees their own data
alice_results = mem.mem_search(user_id=alice, query="theme", limit=5)
bob_results = mem.mem_search(user_id=bob, query="theme", limit=5)
assert all(r.id != b.id for r in alice_results for b in bob_results)

print("Alice stats:", show_dashboard(mem, alice))
print("Bob stats:", show_dashboard(mem, bob))


if __name__ == "__main__":
main()

Concurrency note

ILabMemory is single-threaded by design (v0.1). SQLite is in WAL mode, so concurrent reads from multiple threads are safe; writes are serialized by SQLite's locking. For FastAPI workloads with light-to-moderate write traffic, this is enough — wrap heavy writes in run_in_threadpool if needed. For sustained high-throughput multi-tenant write loads, the per-tenant DB pattern (one file per user_id) is the recommended escalation.

What's next?