Multi-tenant data
In shortIsolate organizations and projects in one Postgres with tenant columns on PgCheckpointer and PgVectorStore, set reach per role and filter your own columns.
- 16 min read
- 13 sections
- Updated
- v1.0.0
- Markdown
PgCheckpointer and PgVectorStore accept two kinds of columns you declare yourself. Tenant columns such as org_id and project_id isolate data the way user_id does. Extra columns such as public_id or plan are plain data you can read and filter. 10xGraph owns a few columns; the rest of the schema is yours. Nothing is created until you declare something.
Tenant columns and extra columns
| Item | Tenant columns | Extra columns |
|---|---|---|
| Purpose | Isolation: who may see the row | Data: stored with the row, filterable |
| Option | tenant_columns= |
extra_columns= |
| Value required on write | Yes, by default | No, by default (missing is NULL) |
| Opt out or opt in | {"type": "text", "required": False} |
{"type": "integer", "required": True} |
| Allowed types | text, bigint, integer, uuid |
Any Postgres type |
Used for reach (tenant) |
Yes | No |
Allowed in client filters |
No, always rejected | Yes |
Both options exist on PgCheckpointer (columns on the threads table) and PgVectorStore (columns on the memories table). Each declared column becomes a real column with a B-tree index.
Reserved names. A tenant or extra column cannot be named kind, tenant, roles, permissions, reach or claims: those are fields of the Identity in config["user"] and carry authorization data, so declaring one raises ValueError. Built-in column names of the table (such as user_id, created_at, memory_key) are refused too. Pick another name, for example org_role.
A declaration is a list of names (each a text column), a mapping of name to type, or a mapping of name to {"type": ..., "required": ...}:
tenant_columns = {"org_id": "uuid", "project_id": "text"}
extra_columns = {
"public_id": "text",
"settings": "jsonb",
"tags": "text[]",
"seats": {"type": "integer", "required": True},
}Names match ^[a-z_][a-z0-9_]{0,62}$, cannot be a column the table already owns, and user_id can never be declared. Every name is validated and quoted, and every value is a bind parameter.
Tenant column types are scalar ids only: text, bigint, integer or uuid. boolean, timestamptz, jsonb, arrays and everything else raise when you build the backend.
Extra column types can be anything Postgres knows. Six types are checked and converted in Python and support the full filter operators: text, bigint, integer, boolean, uuid and timestamptz. Any other type (jsonb, text[], numeric(10,2), date, an enum) gets a cheap syntax check when you declare it. At setup the server resolves it with to_regtype, and only the canonical name the server returns is used in DDL, so the text you declared never reaches SQL. An unknown type fails setup. Values for json and jsonb columns are serialised with json.dumps; other values go to asyncpg as given, and a value its codec cannot encode raises ColumnValueError naming the column and type, never the value. Filters on these columns allow only is_null.
Where the values come from
Every write stores the values found in config["user"]. Every read, update and delete is filtered by the caller’s reach, using the same source. A client that sends org_id in its request config, in a message or in a tool argument changes nothing, because only config["user"] is read.
With the API server and a custom BaseAuth
Return the values from authenticate(). Every key you return ends up in the identity’s claims, and the keys named in auth.tenant_claims become its tenant values. A token with a tenant claim missing is rejected with 401.
from fastapi import HTTPException, Request, Response
from fastapi.security import HTTPAuthorizationCredentials
from tenxgraph_api import BaseAuth
class TenantAuth(BaseAuth):
def authenticate(
self,
request: Request,
response: Response,
credential: HTTPAuthorizationCredentials,
) -> dict | None:
if credential is None:
raise HTTPException(status_code=401, detail="Missing credentials")
claims = verify_token(credential.credentials) # your identity provider; raises on a bad token
return {
"user_id": claims["sub"],
"org_id": claims["org_id"],
"project_id": claims["project_id"],
"public_id": claims["public_id"],
"roles": claims.get("roles", ["member"]),
}{
"agent": "graph.agent:app",
"auth": {
"method": "custom",
"path": "auth.tenant_auth:TenantAuth",
"tenant_claims": ["org_id", "project_id"],
"roles": {
"member": {"permissions": ["graph:*", "threads:*", "store:*", "files:*"], "reach": "own"},
"org_admin": {"inherits": ["member"], "reach": "tenant"}
}
},
"store": "graph.dependencies:store"
}The server uses the checkpointer you pass to graph.compile(checkpointer=...). The store key points to the same PgVectorStore instance you compile with. tenant_claims must list the same names as the tenant columns on both. Boot refuses a mismatch under MODE=production and warns otherwise.
With the API server and JWT
The built-in "auth": "jwt" backend returns the whole decoded token as the user, so every claim ends up in config["user"]. Put org_id, project_id and public_id in the token when your identity provider issues it, next to the required user_id claim:
{
"user_id": "u-42",
"org_id": "7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11",
"project_id": "docs",
"public_id": "usr_8f2k",
"roles": ["org_admin"],
"exp": 1893456000
}The signature is what makes the claims trustworthy, so keep JWT_SECRET_KEY secret and issue these claims only from your own login service. See Set up API authentication.
Core only, without the API server
When you call the compiled graph from your own code, your process is the trust boundary. Build config["user"] yourself from your verified session or token. It takes an Identity or a dict that validates into one; an unknown key raises:
def run_config(session, thread_id: str) -> dict:
"""`session` comes from your own verified login, never from the request body."""
return {
"thread_id": thread_id,
"user": {
"user_id": session.user_id,
"tenant": {"org_id": session.org_id, "project_id": session.project_id},
"claims": {"public_id": session.public_id},
"reach": "own",
},
}
result = await app.ainvoke({"messages": messages}, config=run_config(session, "t-1"))A full runnable version is in examples/long_term_memory/multi_tenant_memory.py.
Declare the same columns on the checkpointer and the store
Threads and memories of one request must be isolated by the same columns. If the checkpointer filters by org_id and the store does not, memories of two tenants with the same user_id mix while their threads do not.
from tenxgraph.storage.checkpointer import PgCheckpointer
from tenxgraph.storage.store import OpenAIEmbedding, PgVectorStore
TENANT_COLUMNS = {"org_id": "uuid", "project_id": "text"}
EXTRA_COLUMNS = {"public_id": "text", "plan": "text"}
checkpointer = PgCheckpointer(
postgres_dsn=POSTGRES_DSN,
redis_url=REDIS_URL,
tenant_columns=TENANT_COLUMNS,
extra_columns=EXTRA_COLUMNS,
)
store = PgVectorStore(
embedding=OpenAIEmbedding(),
dsn=POSTGRES_DSN,
tenant_columns=TENANT_COLUMNS,
extra_columns=EXTRA_COLUMNS,
)compile() checks that both declare the same tenant column names and types. With MODE=production a mismatch raises GraphError with code GRAPH_TENANT_001; in any other mode it logs a warning. Pass allow_tenant_mismatch=True to allow a mismatch on purpose. It never raises then, and logs the mismatch once at INFO level. Use it when the store holds only shared data, for example a knowledge base:
app = graph.compile(checkpointer=checkpointer, store=kb_store, allow_tenant_mismatch=True)The API server runs the same check on a store bound with the store key in 10xgraph.json, so a mismatch there also refuses to boot in production. The opt-out for that store is a top-level key:
{
"store": "graph.stores:kb_store",
"store_allow_tenant_mismatch": true
}Notes on each backend:
PgCheckpointersets the values when a thread is created and never changes them. Every query that touches a thread (threads, states, messages, tool executions) is filtered through the thread row. Schema version 6 adds the columns tothreadswithADD COLUMN IF NOT EXISTS, and a column you declare later is added on the next setup.aget_thread_scope(thread_id)returns{"user_id": ..., "org_id": ...}for a thread (all tenant columns), without scoping to the caller.aget_thread_owneris kept.PgVectorStoreadds missing columns on everyasetup(). The tenant columns also join the uniquememory_keyindex, so the sameuser_idin two tenants keeps separate memories for the same key. Shared data written undersystem_user_id(name)is shared only inside the caller’s tenant values, on every reach.InMemoryCheckpointerandSqliteCheckpointerraise if you passtenant_columns,extra_columnsorrow_level_security. UsePgCheckpointerfor tenant data.
Reach
Reach says which rows a caller’s queries match. You set it per role in auth.roles, and the server puts it on the Identity. Storage reads it on every query.
| Reach | Rows the caller’s queries match |
|---|---|
own |
user_id and every declared tenant column: the caller’s own rows in their tenant |
tenant |
Every declared tenant column, any user: all rows of the caller’s tenant |
all |
No filter: every row of every tenant |
{
"auth": {
"method": "jwt",
"tenant_claims": ["org_id", "project_id"],
"roles": {
"member": {"permissions": ["graph:*", "threads:*", "store:*", "files:*"], "reach": "own"},
"org_admin": {"inherits": ["member"], "reach": "tenant"},
"super_admin": {"permissions": ["*"], "reach": "all"}
}
}
}| Caller | Reach | What they see |
|---|---|---|
| Super admin | all |
Every thread and memory |
| Org admin | tenant |
All threads and memories with the caller’s org_id and project_id |
| User | own |
Their own threads and memories |
Rules to know:
- Reach widens reads, lists and deletes only. Runs and state or message writes always stay
own, admins included. Nobody runs inside another user’s thread. Stop follows reach. - Reach is never a fallback. A role with a missing or unknown reach refuses to boot. A token cannot name its own reach.
- A picked column needs a value. Reach
ownneeds a value for every declared tenant column inIdentity.tenant. A missing value raises, and the API answers 401 because the token lacks a tenant claim. The column is never dropped from the filter quietly. - Reach
tenantneeds tenant columns.InMemoryCheckpointerandSqliteCheckpointerdeclare none. On them a read, list or delete by a caller with reachtenantraisesValueError, so the request fails; it never falls back toown. Writes useownand still work. UsePgCheckpointerwith tenant columns for any role with reachtenant. - Reach
tenantmatches every declared tenant column. All declared columns must equal the caller’s values. A role cannot pick a subset. - A foreign row is a 404. A thread outside the caller’s reach looks the same as a missing one.
- Store calls still carry a
user_id. Writes andaforget_memoryalways name an owner, whatever the reach is. - Shared data stays shared inside the tenant. Rows written under a
__system__:<name>owner (agent memory, knowledge bases) are pinned to that owner on every reach, so a wider reach never reads other users’ rows through a shared-data query.
In core-only use, put the reach on the identity yourself:
from tenxgraph.core.authz import Identity, Reach
config["user"] = Identity(user_id=session.user_id, tenant={"org_id": "acme"}, reach=Reach.TENANT)Without any identity, each backend applies its own default, and the filter helper owner_filter(config, tenant_columns) returns None.
Filters
filters narrows a listing or search on the columns you declared. It is ANDed after the isolation conditions, so a filter can only narrow what the caller may already see.
threads = await checkpointer.alist_threads(
config, filters={"plan": "pro", "created_at": {"gte": "2026-01-01T00:00:00+00:00"}}
)
hits = await store.asearch(config, "billing", filters={"category": {"in": ["faq", "policy"]}})Format: {"column": value} means equality; {"column": {"op": value, ...}} applies one or more operators.
Operators by type
| Column type | Operators |
|---|---|
text |
eq, ne, in, nin, like, ilike, is_null |
bigint, integer, timestamptz |
eq, ne, gt, gte, lt, lte, in, nin, is_null |
boolean, uuid |
eq, ne, in, nin, is_null |
| Any other extra column type | is_null only |
Values: text takes a string (or an integer, sent as its decimal string); integer and bigint take an integer (not a bool) or a string of digits, in range; boolean takes a bool; uuid takes a UUID or its string; timestamptz takes an ISO 8601 string with an offset. Floats are never accepted, and None is not a value: use is_null.
Filterable columns
| Backend | Built-in columns | Plus |
|---|---|---|
Threads (PgCheckpointer, GET /v1/threads) |
thread_name, created_at, updated_at |
Your extra columns |
Memories (PgVectorStore) |
memory_type, category, thread_id, memory_key, created_at, updated_at |
Your extra columns |
On the store, a key that is not a column matches a top-level key of the JSONB metadata by equality, with str, int, float or bool values only.
Behaviour to know
- NULL.
eq,gt,gte,ltandltenever match a row whose column isNULL.neandnintreatNULLas a value of its own, so a row with no value does match “not equal to x”. Add{"is_null": false}on the same column to leave those rows out. likeandilike(text only). The value is the pattern;%and_are wildcards and a backslash escapes them. A pattern with a leading%scans every row the caller can see. See Contains search withpg_trgm.- Limits. At most 10 conditions per request (a column with two operators counts as two, and metadata keys count), at most 100 items in an
inorninlist (never empty), at most 256 characters per string value. Conditions are joined with AND only. - Rejected. Tenant columns and
user_idare isolation columns, so a filter that names one is rejected. So is an unknown column, an operator the column’s type does not support, and a malformed value. The error names the column and operator but never repeats a value and never contains SQL. In core these raiseFilterError, aValueError.
Filtering threads over the API
GET /v1/threads takes a filters query parameter holding a JSON object, URL-encoded:
curl -G https://api.example.com/v1/threads \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"plan": "pro", "created_at": {"gte": "2026-01-01T00:00:00+00:00"}}'The server validates it with the core validator before any database work and answers 422 when:
- the value is not valid JSON, is not a JSON object, or is longer than 8192 characters;
- a column is not filterable, or names a tenant column or
user_id; - an operator or value does not fit the column’s type, or a limit is exceeded;
- the checkpointer has no filterable columns (the in-memory and SQLite checkpointers).
The store routes take the same kind of filters object in the request body (for example on POST /v1/store/search), and bad filters answer 422 the same way.
Native row-level security
The WHERE filter is always on. For defense in depth you can add Postgres row-level security (RLS) on top:
checkpointer = PgCheckpointer(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)
store = PgVectorStore(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)It needs at least one tenant column. Setup runs ENABLE and FORCE ROW LEVEL SECURITY and creates a policy that compares each tenant column with current_setting('tenxgraph.<column>', true). On the checkpointer the policy covers threads, and states, messages and tool_executions are visible only when their thread is. Every call then runs in a transaction that sets those values with set_config(..., true) (SET LOCAL).
- Cost. One transaction and one extra statement per call, plus a thread lookup per child row on the checkpointer. It is off by default.
- Superusers and roles with
BYPASSRLSskip RLS. A warning is logged when the connected role does. Connect with an ordinary role. The table owner is filtered only because ofFORCE. - Maintenance calls that deliberately cross tenants (
aget_thread_scope,abackfill_embeddings) run with no tenant values set, which the policy reads as all rows. - Turning it off. Keep the tenant columns and set
row_level_security=False: the next setup drops the policies and disables RLS. This works the same on the store.
Backfill old rows
Rows written before a tenant column existed hold NULL in it. Reach own and tenant never match them, so those threads and memories become invisible to every caller on those reaches, admins included, until you fill them in. Reach all has no filter and still sees them.
After declaring the column and running setup, update the old rows yourself:
UPDATE public.threads SET org_id = '7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11' WHERE org_id IS NULL;
UPDATE public.tenxgraph_memories SET org_id = '7b0e2b3a-9a57-4b14-8e1b-2a1e3f1c9c11' WHERE org_id IS NULL;Map each old user_id to its tenant in your own data. If your tenant column was added as uuid, use real UUID values.
Add tenant columns to a PgVectorStore table that has data
The memory_key unique index gains the tenant columns, so the same user_id in two tenants keeps separate keyed memories. On a table in use, roll it out in three steps:
- Deploy the new version with
tenant_columns. Setup adds the columns and builds the new index<table>_kuq_<hash>withCREATE INDEX CONCURRENTLY. It keeps the old index<table>_key_uq, because pods still on the previous version need it as theON CONFLICTtarget of their keyed writes. While both indexes exist, a keyed write for the same(collection, user_id, memory_type, memory_key)in a second tenant fails withValueError, whose message names the old index. Nothing else changes, and setup logs a warning that the old index is kept. - Backfill the tenant values of old rows once every pod runs the new version (see Backfill old rows). Old rows hold
NULLand are invisible to reachownandtenant. - Drop the old index once:
dropped = await store.adrop_legacy_key_index() # names of the indexes it dropped, [] if noneIt runs DROP INDEX CONCURRENTLY under the setup lock and ddl_lock_timeout, and only after it checks that a valid index for the current declaration exists. It raises RuntimeError if none does (run asetup() first).
Setup on a table that is in use
PgCheckpointer and PgVectorStore take ddl_lock_timeout (seconds, default 10.0; None waits forever). It is the Postgres lock_timeout for every setup DDL statement. A statement that cannot get its table lock in time fails with tenxgraph.storage.columns.SetupLockTimeoutError (a RuntimeError) instead of hanging behind an open transaction, which would also queue every later read of the table behind it. The statement that timed out changed nothing, so retry setup after the other transaction ends, or raise the timeout.
- Setup skips what is in place. It reads the catalog first and runs no DDL for anything that exists, so a restart takes no table lock.
- Indexes on a table already in use are built
CONCURRENTLY, so reads and writes continue. Adding a declared column is a shortALTER TABLEunder the timeout. - A crashed build is repaired. An index left invalid by a build that failed or was cancelled is dropped and built again on the next setup.
- Setup is serialised across processes by a session-level advisory lock, which also covers the concurrent builds.
Uploaded files
Uploaded files follow the same tenant isolation as threads and memories. When the checkpointer declares tenant columns, each upload records the uploader’s user_id and tenant values, taken only from config["user"]. Every later use of the file must match all of them:
- download,
/info,/urland delete (a mismatch answers 404) GET /v1/files, which lists only the caller’s files in their tenant- any
graph://media/orfile_idreference in a run,PUT stateorPOST messages
Reads and deletes follow the caller’s reach: a role with reach tenant can read and delete the files of its tenant, and reach all any file. Uploads always belong to the uploader. The same user_id in two organisations is two identities: neither sees the other’s files, and each has its own upload quota. An identity with no tenant value cannot upload (403) or read files.
Files uploaded before tenant columns were declared carry no tenant values and are denied. To keep them, backfill each file’s metadata with {"tenant": {"org_id": "<value>"}}, using the canonical text form (for example a lowercase UUID).
Deleting a thread, including by an org admin, releases the files the thread’s owner linked to it; media.delete_with_thread still decides whether an unlinked file is deleted.
RAGAgent and tenant stores
RAGAgent forwards only config["user"] from the run config into the config it passes to store.asearch, so a store with tenant columns scopes knowledge-base searches to the caller’s tenant. It pins the forwarded identity to the knowledge-base owner (system_user_id("knowledge_base") by default) with reach own, so a caller with reach tenant or all cannot widen a knowledge-base search to other users’ rows. The tenant values are read from the run config only, never from state, messages or model output.
- Documents written with tenant values are visible only inside that tenant.
- To share one knowledge base across tenants, use a store without tenant columns, and compile with
allow_tenant_mismatch=Truewhen the checkpointer declares tenant columns.
Contains search with pg_trgm
like '%word%' and ilike '%word%' scan every row the caller can see, because a B-tree index cannot serve a leading wildcard. For a large table, add a trigram index on the column you search. 10xGraph does not create it for you:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX threads_title_trgm ON public.threads USING gin (title gin_trgm_ops);title stands for an extra column you declared. Creating the extension needs the same kind of permission as vector.
Next steps
- To set up the store: How to use PgVectorStore.
- For checkpointer options: Set up checkpointing.
- For permissions inside nodes and tools: Authorization scopes.
- For authentication and ownership on the server: Set up API authentication and authorization.
- For runnable code:
examples/long_term_memory/multi_tenant_memory.pyandexamples/long_term_memory/pgvector_memory.py.