# Multi-tenant data

> Isolate organizations and projects in one Postgres with tenant columns on PgCheckpointer and PgVectorStore, set reach per role and filter your own columns.

Source: https://10xgraph.com/docs/guides/multi-tenant-data
Last updated: 2026-11-08

`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

| | 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": ...}`:

```python title="columns.py"
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

> **Trusted source rule**
>
> Tenant values come only from the `Identity` in `config["user"]`, taken from the claims you list in `auth.tenant_claims`. Extra column values come from the same identity's claims. `user` is a reserved config key: clients cannot set it. Your auth class fills it (the `BaseAuth.authenticate()` result or JWT claims). Return `org_id`, `project_id` and `public_id` there. Never read them from request config, metadata, state or model output.

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.

```python title="auth/tenant_auth.py"
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"]),
        }
```

```json title="10xgraph.json"
{
  "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:

```json title="token payload"
{
  "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](/docs/server/auth).

### 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:

```python title="run.py"
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.

```python title="graph/dependencies.py"
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:

```python
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:

```json
{
  "store": "graph.stores:kb_store",
  "store_allow_tenant_mismatch": true
}
```

Notes on each backend:

- **`PgCheckpointer`** sets 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 to `threads` with `ADD 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_owner` is kept.
- **`PgVectorStore`** adds missing columns on every `asetup()`. The tenant columns also join the unique `memory_key` index, so the same `user_id` in two tenants keeps separate memories for the same key. Shared data written under `system_user_id(name)` is shared only inside the caller's tenant values, on every reach.
- **`InMemoryCheckpointer` and `SqliteCheckpointer`** raise if you pass `tenant_columns`, `extra_columns` or `row_level_security`. Use `PgCheckpointer` for 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 |

```json title="10xgraph.json"
{
  "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 `own` needs a value for every declared tenant column in `Identity.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 `tenant` needs tenant columns.** `InMemoryCheckpointer` and `SqliteCheckpointer` declare none. On them a read, list or delete by a caller with reach `tenant` raises `ValueError`, so the request fails; it never falls back to `own`. Writes use `own` and still work. Use `PgCheckpointer` with tenant columns for any role with reach `tenant`.
- **Reach `tenant` matches 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 and `aforget_memory` always 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:

```python
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.

```python
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`, `lt` and `lte` never match a row whose column is `NULL`. `ne` and `nin` treat `NULL` as 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.
- **`like` and `ilike`** (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 with `pg_trgm`](#contains-search-with-pg_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 `in` or `nin` list (never empty), at most 256 characters per string value. Conditions are joined with AND only.
- **Rejected.** Tenant columns and `user_id` are 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 raise `FilterError`, a `ValueError`.

### Filtering threads over the API

`GET /v1/threads` takes a `filters` query parameter holding a JSON object, URL-encoded:

```bash
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:

```python
checkpointer = PgCheckpointer(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)
store = PgVectorStore(..., tenant_columns=TENANT_COLUMNS, row_level_security=True)
```

> **Enable it in a full rollout, not pod by pod**
>
> With `FORCE`, a pod that does not set the tenant values sees zero rows. Old pods (the version before you turned `row_level_security=True` on) set no settings, so as soon as setup runs on the first new pod, every old pod reads and writes nothing useful until it runs the new version. Stop the old pods, start the new version, then serve traffic. Do not turn it on while old and new pods share the database.

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 `BYPASSRLS` skip RLS.** A warning is logged when the connected role does. Connect with an ordinary role. The table owner is filtered only because of `FORCE`.
- **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:

```sql
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

> **Deprecated: legacy key index handling**
>
> `adrop_legacy_key_index()` and the handling that keeps the pre-tenant `<table>_key_uq` index next to the new one are transitional and deprecated. They will be removed in the next breaking release (1.5 or 2.0). After that, setup replaces the old index directly, and the rollout below needs no extra step.

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:

1. **Deploy** the new version with `tenant_columns`. Setup adds the columns and builds the new index `<table>_kuq_<hash>` with `CREATE INDEX CONCURRENTLY`. It keeps the old index `<table>_key_uq`, because pods still on the previous version need it as the `ON CONFLICT` target 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 with `ValueError`, whose message names the old index. Nothing else changes, and setup logs a warning that the old index is kept.
2. **Backfill** the tenant values of old rows once every pod runs the new version (see [Backfill old rows](#backfill-old-rows)). Old rows hold `NULL` and are invisible to reach `own` and `tenant`.
3. **Drop the old index** once:

```python
dropped = await store.adrop_legacy_key_index()   # names of the indexes it dropped, [] if none
```

It 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 short `ALTER TABLE` under 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`, `/url` and delete (a mismatch answers 404)
- `GET /v1/files`, which lists only the caller's files in their tenant
- any `graph://media/` or `file_id` reference in a run, `PUT state` or `POST 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=True` when 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:

```sql
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](/docs/guides/use-pgvector-store).
- **For checkpointer options:** [Set up checkpointing](/docs/guides/set-up-checkpointing).
- **For permissions inside nodes and tools:** [Authorization scopes](/docs/guides/authorization-scopes).
- **For authentication and ownership on the server:** [Set up API authentication and authorization](/docs/server/auth).
- **For runnable code:** `examples/long_term_memory/multi_tenant_memory.py` and `examples/long_term_memory/pgvector_memory.py`.
