turbopuffer namespace design: one namespace per tenant, or shared with filters?
Draw turbopuffer namespaces around what is queried together. For multi-tenant apps that means one namespace per tenant. The billing rules, limits, deletes and schema behaviour that decide it, with the code.
Draw turbopuffer namespaces around the set of documents that one query should search. For a multi-tenant SaaS or RAG product, that means one namespace per tenant, with attribute filters used only for access control inside a tenant. A shared namespace with a tenant_id filter works, but every query is billed for the whole shared namespace, and all tenants compete for one namespace's concurrency and write limits.
That is also what the vendor recommends. The concepts page says to create "one namespace per set of documents that are expected to be returned in the same query rather than using filters to separate data", and the query docs repeat it under filters. This post covers why, with the billing arithmetic, the limits that bite a shared namespace, schema choices, write patterns, and the application code that keeps tenants apart. Everything was checked against turbopuffer's documentation as of October 2026. We had no account to run it against, so the code is written to the documented Python SDK and labelled illustrative, and every number is either quoted from the docs or computed from them with the formula shown.
What a namespace is in turbopuffer
A namespace is an isolated set of documents with its own prefix on object storage, its own vector, full-text and attribute indexes, and its own schema. It is created implicitly by the first write, or explicitly with create_namespace. Names must match [A-Za-z0-9-_.]{1,128}. The limits page lists the maximum number of namespaces as unlimited, with 250M+ seen in production, because a namespace is only a prefix in a bucket.
That last point is what makes per-tenant layouts cheap here. An idle namespace costs its storage, and the pricing page lists no per-namespace charge. The performance guide states the rule of thumb as making namespaces "as small as they can be without having to routinely query more than one at a time."
Per-tenant vs shared turbopuffer namespaces
The two layouts differ on more than query speed. This table lines them up against the documented behaviour.
| Concern | One namespace per tenant | One shared namespace, tenant_id filter |
|---|---|---|
| Data queried per billed query | Tenant size, floored at 1.28 GB | Whole shared namespace, with large-namespace discounts |
| Cold start | Each tenant goes cold on its own | Any tenant's traffic keeps the cache warm |
| Concurrent query limit (16 per unpinned namespace) | Per tenant | Shared by every tenant |
| Write limit (10k writes/s, 2 GB unindexed) | Per tenant | Shared by every tenant |
| Offboarding a tenant | delete_all(), one call | delete_by_filter, max 5M rows per request |
| Schema | Can differ per tenant | One schema, max 1,024 attribute names for everyone |
| Cross-tenant query | Not possible in one request | Possible |
| Isolation bug surface | Wrong namespace name | Missing or wrong filter on any query |
Query billing
Queries are billed for data queried plus data returned. Per turbopuffer pricing as of October 2026, data queried is "the actual size of the queried namespace or 1.28 GB, whichever is greater", with an 80% marginal discount between 32 and 128 GB and a 96% marginal discount above 128 GB, at $1 per PB on Launch and Scale. A filter does not shrink the namespace you are billed for. So a filtered query in a shared namespace is priced on everyone's data.
The billed size of one query against a namespace of S GB is:
billed(S) = min(S, 32)
+ 0.20 * (min(S, 128) - 32) if S > 32
+ 0.04 * (S - 128) if S > 128
with S floored at 1.28
Take an illustrative workload: 1,000 tenants of 100 MB each (100 GB in total) and 10 million queries a month, each query searching one tenant's data. Data returned is the same in both layouts, so it is left out.
| Layout | Billed per query | Monthly data queried | Cost at $1/PB |
|---|---|---|---|
| 1,000 namespaces of 100 MB | 1.28 GB (floor) | 10M x 1.28 GB = 12.8 PB | $12.80 |
| One 100 GB namespace | 32 + 0.2 x 68 = 45.6 GB | 10M x 45.6 GB = 456 PB | $456.00 |
Storage is identical either way: 100 GB x $0.33 per GB-month = $33, plus a little extra in the shared layout for the tenant_id attribute on every row. The shared layout pays about 35.6 times more for queries in this example, and the gap grows with tenant count until the 96% discount flattens it.
xychart-beta
title "Billed GB per query, computed from published pricing"
x-axis "Shared namespace size, GB" [1.28, 10, 32, 100, 128, 1000]
y-axis "Billed GB per query" 0 --> 90
bar [1.28, 10, 32, 45.6, 51.2, 86.08]
line [1.28, 1.28, 1.28, 1.28, 1.28, 1.28]Blue bars are a query against a shared namespace of that size. The grey line is a query against a per-tenant namespace when every tenant is under the 1.28 GB floor. One exact consequence: since the shared namespace is always at least as large as any tenant in it, per-tenant query billing is never higher. The two tie only while the whole shared namespace stays under 1.28 GB.
Cold start
Here the shared layout has a real advantage. Every query to the shared namespace keeps it in the NVMe and memory cache, while a per-tenant namespace that nobody has touched for a while goes cold. The limits page gives the current average cache SSD expiry as "hours". With thousands of rarely queried tenants, a good share of first queries will hit object storage. The fix is to send hint_cache_warm when a tenant's user opens a session, which our post on turbopuffer cold start latency covers with the documented numbers and billing rule. One hint per session for one tenant namespace is the cheap case.
Limits that a shared namespace concentrates
Per-namespace limits turn into per-customer limits in a shared layout, so one tenant's bulk import or traffic spike reaches every other tenant. These are from the limits page, as of October 2026.
| Limit | Value | Why it matters for layout |
|---|---|---|
| Namespaces | Unlimited (250M+ seen) | Per-tenant is safe at any tenant count |
| Concurrent queries per unpinned namespace | 16; the 17th waits up to 800ms, then HTTP 429 | Shared: every tenant queues behind the same 16 |
| Write throughput per namespace | 10k writes/s @ 32 MB/s | Shared: one tenant's backfill eats everyone's budget |
| Unindexed data per namespace | 2 GB, then upserts return HTTP 429 | Shared: one large import blocks all tenants' writes |
| Documents per namespace | 128B @ 256TB with sharding; 500M @ 1TB per shard | Only a very large single tenant needs sharding |
| Attribute names per namespace | 1,024 | Shared: tenant-specific custom fields add up |
| Vector columns per namespace | 8, fixed at creation | Shared: every tenant must use the same embedding layout |
delete_by_filter rows per request | 5M | Shared: large tenants need several delete requests |
The concurrency footnote is explicit that the 16-query limit exists "to mitigate the noisy neighbour effect". A shared namespace brings that noisy neighbour problem back inside your own product.
Deletes and exports
Offboarding a per-tenant namespace is ns.delete_all(). The delete namespace docs say it removes the namespace and all its documents and "there is no way to recover a deleted namespace", which is the property you want for a deletion request and the one to guard with a confirmation step.
In a shared namespace the same job is a delete_by_filter on tenant_id. It is billed as normal deletes plus one query per request (on the whole shared namespace), fails outright above 5M matching rows unless you set delete_by_filter_allow_partial, and the concepts page says deleted data is removed later, when periodic compaction runs. Exports follow the same pattern. A tenant namespace can be paged by id or copied server side with copy_from_namespace. Exporting one tenant from a shared namespace means paging with a tenant_id filter, or copying the entire namespace and deleting everyone else, which the export docs suggest for subsets.
Designing the attribute schema
Each namespace has its own schema. Types are inferred from the first write, except types that cannot be inferred, such as uuid, uint and datetime, which must be declared. Changing an attribute's type is an error, and attribute deletions cannot be done in place. The only in-place changes the write docs list are filterable, full_text_search, regex, glob, fuzzy and adding or removing embed. Anything else means exporting and upserting into a new namespace. With per-tenant namespaces you can do that one tenant at a time behind a flag. With a shared namespace it is one big migration.
The rules that change cost:
- Every attribute is filterable by default and billed for its index. Set
filterable: falseon attributes you only return, such as source URLs or raw JSON, for a 50% discount. full_text_search: truesetsfilterabletofalse. An attribute with both enabled is billed at 200% of its logical size.- Declare ACL arrays as
[]uuid. The permissions guide warns that otherwise they are inferred as "a slower and more expensive string type". - Use an
is_publicboolean for documents everyone can read. The same guide explains that filtering on an empty array has nothing to index and must post-filter. distance_metricapplies to every vector column in the namespace, and vector columns are fixed at creation.
The schema can be sent on every write with no performance difference, so the simplest approach is to send it with every write. Then whichever write creates a tenant's namespace creates it with the right types.
# Illustrative: written to the documented Python SDK, not run against a live account.
import re
import turbopuffer
tpuf = turbopuffer.Turbopuffer(region="gcp-us-central1") # reads TURBOPUFFER_API_KEY
SCHEMA = {
"id": "uuid",
"vector": {"type": "[768]f16", "ann": True},
"content": {"type": "string", "full_text_search": True},
"doc_id": "uuid",
"acl_groups": "[]uuid",
"is_public": "bool",
"updated_at": "datetime",
"source_url": {"type": "string", "filterable": False},
}
_TENANT = re.compile(r"[0-9a-f]{32}")
def tenant_ns(tenant_id: str, corpus: str = "docs"):
"""tenant_id must come from the authenticated session, never from request input."""
if not _TENANT.fullmatch(tenant_id):
raise ValueError("invalid tenant id")
return tpuf.namespace(f"prod.{corpus}.t-{tenant_id}") # 44 bytes, limit is 128
The prod. prefix keeps environments apart and makes tpuf.namespaces(prefix="prod.docs.") list exactly one corpus. Per the testing guide, creating a namespace is "virtually free", so tests can use random namespace names and delete them afterwards.
Write patterns
Writes go to one namespace per request, so per-tenant layouts cannot pack many tenants' documents into one request. That matters because of the write billing rules: every request is billed at least 10 KB, and the batch discount is min(0.5, max(0, 0.2 * (log10(KB) - 1))), reaching 50% around 3.2 MB. For an illustrative 1M documents of 4 KB each:
| Write pattern | Billable per request | Billable total | Cost at $2/GB |
|---|---|---|---|
| One document per request | 10 KB (minimum) | 10 GB | $20.00 |
| Per-tenant buffer of 100 docs (400 KB, 32.04% off) | 271.84 KB | 2.72 GB | $5.44 |
| Shared namespace, 1,000 docs (4 MB, 50% off) | 2,000 KB | 2 GB | $4.00 |
Buffering per tenant gets most of the shared layout's discount. Flush on size or age, and run flushes for different tenants concurrently, since they are separate namespaces.
# Illustrative buffered writer: one buffer per tenant namespace.
from collections import defaultdict
BATCH_ROWS = 500
_buffers: dict[str, list[dict]] = defaultdict(list)
def enqueue(tenant_id: str, row: dict) -> None:
_buffers[tenant_id].append(row)
if len(_buffers[tenant_id]) >= BATCH_ROWS:
flush(tenant_id)
def flush(tenant_id: str) -> None:
rows, _buffers[tenant_id] = _buffers[tenant_id], []
if rows:
tenant_ns(tenant_id).write(
upsert_rows=rows,
distance_metric="cosine_distance",
schema=SCHEMA,
)
A write returns once it is durable on object storage (the write docs give a p50 of 165ms for 512 KB batches), and strongly consistent queries, the default, see it immediately. If a backfill approaches the 2 GB unindexed limit, the namespace returns HTTP 429. disable_backpressure lifts that for bulk loads, but strongly consistent queries then error above the threshold, so only use it on tenants that are not serving traffic yet.
Tenant isolation in the application layer
The security docs describe isolation between turbopuffer customers: API keys resolve to an organization, and data files sit under that organization's prefix. Inside your organization, we found no namespace-scoped API keys in the docs as of October 2026, so any key your backend holds can read any tenant's namespace. The boundary between your customers is your code.
sequenceDiagram
participant U as User
participant API as Your backend
participant Auth as Auth layer
participant T as turbopuffer
U->>API: search request with session token
API->>Auth: verify token
Auth-->>API: tenant_id, user groups
Note over API: namespace name built from tenant_id<br/>never from the request body
API->>T: POST /v2/namespaces/prod.docs.t-TENANT/query<br/>filters: is_public OR acl_groups
T-->>API: rows
API-->>U: resultsPer-tenant namespaces reduce the isolation surface to one function, tenant_ns, which is easy to review and test. Inside the tenant, document permissions are still filters, because the permissions guide states turbopuffer "doesn't provide built-in mechanisms for row/document-level RBAC".
# Illustrative query: tenant boundary from the namespace, user ACL from filters.
def search(tenant_id: str, user_groups: list[str], vec: list[float], k: int = 10):
return tenant_ns(tenant_id).query(
rank_by=("vector", "ANN", vec),
filters=("Or", (
("is_public", "Eq", True),
("acl_groups", "ContainsAny", user_groups),
)),
limit=k,
include_attributes=["content", "source_url"],
)
If you do run a shared namespace, never let callers build filters themselves. Wrap every read and delete in one function that ANDs the tenant condition onto whatever else it receives, and make that function the only code that holds the namespace handle.
# Illustrative shared-namespace guard and offboarding.
SHARED = tpuf.namespace("prod.docs.shared")
def shared_query(tenant_id: str, filters=None, **kwargs):
scope = ("tenant_id", "Eq", tenant_id)
combined = ("And", (scope, filters)) if filters else scope
return SHARED.query(filters=combined, **kwargs)
def offboard_shared(tenant_id: str) -> None:
while True:
r = SHARED.write(
delete_by_filter=("tenant_id", "Eq", tenant_id),
delete_by_filter_allow_partial=True,
)
if not r.rows_remaining:
break
def offboard_per_tenant(tenant_id: str) -> None:
tenant_ns(tenant_id).delete_all() # unrecoverable: confirm before calling
A multi-query (up to 16 subqueries) runs against one namespace, so a tenant that also searches a shared public corpus needs two requests in parallel and a client-side merge. Reciprocal rank fusion is the usual way to combine the two ranked lists without comparing raw scores.
Choosing a layout
flowchart TD
A(["New corpus"]) --> B{"Does one query need<br/>documents from many tenants?"}
B -- yes --> S["Shared namespace<br/>tenant or ACL filters"]
B -- no --> C{"Whole corpus under 1.28 GB<br/>and tenants rarely queried?"}
C -- yes --> W["Shared namespace is defensible:<br/>same query billing, warmer cache"]
C -- no --> P["One namespace per tenant"]
P --> D{"Any single tenant near<br/>500M docs or 1 TB?"}
D -- yes --> SH["Shard that tenant's namespace"]
D -- no --> H["Warm on session start,<br/>ACL filters inside the tenant"]| Situation | Layout |
|---|---|
| B2B SaaS, each customer searches only its own documents | One namespace per tenant |
| Thousands of small tenants, total corpus under 1.28 GB, sporadic traffic | Shared is acceptable; query billing ties and the cache stays warm |
| Public or global corpus that every tenant searches | Its own shared namespace, merged with the tenant namespace client side |
| Users within one tenant with different permissions | Per-tenant namespace plus acl_groups / is_public filters |
| Tenants on different embedding models or dimensions | One namespace per tenant, required |
| One tenant over 500M documents or 1 TB | That tenant's namespace with sharding set on its first write |
| Analytics across all tenants | A separate shared copy built for it, outside the serving path |
Our recommendation: start with one namespace per tenant and a single tenant_ns function that owns the naming, warm the namespace when a user's session starts, and keep filters for permissions inside a tenant. Choose a shared namespace only when queries really do cross tenants, or when the whole corpus fits under the 1.28 GB query floor. The turbopuffer vs Pinecone comparison covers how the two engines model tenancy, and the vector sizing calculator estimates per-tenant namespace sizes before you pick. Layout is only half of a retrieval system's quality, as how RAG fails in production shows. The rest is chunking and evaluation.
Where we can help
We design turbopuffer namespace layouts, schemas and write paths around how your tenants and agents actually query, and check the cost and latency on your data before it ships. See our turbopuffer consulting service.
Abhilash B is an advisor at Query Quotient and an information retrieval and AI expert who holds patents in the field. Abhilash brings that retrieval depth to the search, RAG and agent systems Query Quotient builds for clients.
