Back to blog
Architecture

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.

Abhilash B13 min read
#turbopuffer#multi-tenancy#vector search#rag#architecture

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.

ConcernOne namespace per tenantOne shared namespace, tenant_id filter
Data queried per billed queryTenant size, floored at 1.28 GBWhole shared namespace, with large-namespace discounts
Cold startEach tenant goes cold on its ownAny tenant's traffic keeps the cache warm
Concurrent query limit (16 per unpinned namespace)Per tenantShared by every tenant
Write limit (10k writes/s, 2 GB unindexed)Per tenantShared by every tenant
Offboarding a tenantdelete_all(), one calldelete_by_filter, max 5M rows per request
SchemaCan differ per tenantOne schema, max 1,024 attribute names for everyone
Cross-tenant queryNot possible in one requestPossible
Isolation bug surfaceWrong namespace nameMissing 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.

LayoutBilled per queryMonthly data queriedCost at $1/PB
1,000 namespaces of 100 MB1.28 GB (floor)10M x 1.28 GB = 12.8 PB$12.80
One 100 GB namespace32 + 0.2 x 68 = 45.6 GB10M 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.

LimitValueWhy it matters for layout
NamespacesUnlimited (250M+ seen)Per-tenant is safe at any tenant count
Concurrent queries per unpinned namespace16; the 17th waits up to 800ms, then HTTP 429Shared: every tenant queues behind the same 16
Write throughput per namespace10k writes/s @ 32 MB/sShared: one tenant's backfill eats everyone's budget
Unindexed data per namespace2 GB, then upserts return HTTP 429Shared: one large import blocks all tenants' writes
Documents per namespace128B @ 256TB with sharding; 500M @ 1TB per shardOnly a very large single tenant needs sharding
Attribute names per namespace1,024Shared: tenant-specific custom fields add up
Vector columns per namespace8, fixed at creationShared: every tenant must use the same embedding layout
delete_by_filter rows per request5MShared: 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: false on attributes you only return, such as source URLs or raw JSON, for a 50% discount.
  • full_text_search: true sets filterable to false. 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_public boolean for documents everyone can read. The same guide explains that filtering on an empty array has nothing to index and must post-filter.
  • distance_metric applies 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 patternBillable per requestBillable totalCost at $2/GB
One document per request10 KB (minimum)10 GB$20.00
Per-tenant buffer of 100 docs (400 KB, 32.04% off)271.84 KB2.72 GB$5.44
Shared namespace, 1,000 docs (4 MB, 50% off)2,000 KB2 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: results

Per-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"]
SituationLayout
B2B SaaS, each customer searches only its own documentsOne namespace per tenant
Thousands of small tenants, total corpus under 1.28 GB, sporadic trafficShared is acceptable; query billing ties and the cache stays warm
Public or global corpus that every tenant searchesIts own shared namespace, merged with the tenant namespace client side
Users within one tenant with different permissionsPer-tenant namespace plus acl_groups / is_public filters
Tenants on different embedding models or dimensionsOne namespace per tenant, required
One tenant over 500M documents or 1 TBThat tenant's namespace with sharding set on its first write
Analytics across all tenantsA 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.

Advisor, Query Quotient

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.

Keep reading

Building something similar?

Tell us what you are working on. In a free 30-minute call we will give you an honest view of the approach, the effort and how to measure success.