skip to content

When should Qdrant tenants be separated by payload filter rather than by separate collections?

level: principalimportance: should knowfreq 30%

answer

  1. count your tenants first
  2. fixed overhead times tenant count
  3. the flag that names the partition key
  4. isolation lives in the query
  5. one chokepoint, no raw client

basics

~20 s

Payload partitioning — one collection, a tenant_id in every payload, a keyword index marked is_tenant, and a mandatory filter on every query — scales to many small tenants. Separate collections suit few large tenants needing hard isolation or independent tuning.

solid answer

~50 s

The default for multi-tenant workloads in Qdrant is a single collection with a `tenant_id` payload field, a keyword payload index created with `is_tenant=True`, and a filter on that field attached to every read. The tenant flag tells Qdrant the field partitions the data, so it can co-locate a tenant's points and keep filtered traversal within a tenant efficient. This scales to thousands of small tenants, because a collection carries fixed overhead — segments, graph structure, optimizer threads — that you do not want to multiply by tenant count. Separate collections earn their keep in the opposite regime: a handful of large tenants, per-tenant tuning or vector dimensions, hard blast-radius isolation, or a compliance requirement that data be physically separable and independently deletable. The operational risk of the payload approach is stark: isolation is enforced by the query, so any code path that forgets the filter leaks other tenants' data. That argues for a single chokepoint in the client layer that no caller can bypass.

code

python · 10 lines
python
from qdrant_client import models

client.create_payload_index(
    collection_name="docs",
    field_name="tenant_id",
    field_schema=models.KeywordIndexParams(
        type=models.KeywordIndexType.KEYWORD,
        is_tenant=True,
    ),
)

go deeper

for a junior

Know the two options — a tenant_id payload field with a filter on every query, versus one collection per tenant — and that the filter approach is the common default.

for a middle

Explain why per-collection overhead makes payload partitioning the right default at scale, and that the tenant field needs a keyword payload index marked is_tenant.

for a senior

Show the operational discipline: one repository chokepoint that injects the tenant filter into reads and writes alike, plus a test proving a tenant-less query fails rather than leaking.

for a principal

Own the decision and its exit ramp — when skew, per-collection configuration or compliance forces a tenant into its own collection, and how the read path is designed so that promotion is a migration, not a rewrite.

## Two shapes **Collection per tenant.** Each tenant gets its own collection, its own vector index, its own configuration. Isolation is structural; deleting a tenant is dropping a collection. **Payload partitioning.** One collection holds everyone. Every point's payload carries `tenant_id`, and every query carries a filter on it. ## Why payload partitioning is the default A collection is not free. It has segments, an index structure, optimizer work, and memory that does not amortise across tenants. Multiply that by a few thousand tenants — many of which hold a few hundred points — and the fixed overhead dominates the actual data. Payload partitioning turns the tenant boundary into a filter, which is a thing Qdrant is specifically built to make fast. The mechanism that makes it work is the tenant-aware payload index: create the keyword index on the tenant field with `models.KeywordIndexParams(type=models.KeywordIndexType.KEYWORD, is_tenant=True)`. That flag declares the field to be the partition key, letting Qdrant organise storage so one tenant's points sit together instead of being scattered across the collection — which is what keeps a tenant-filtered query from paying for the whole collection's size. Related tuning exists in the collection's `hnsw_config`, where payload-aware links can be built for such a field so that traversal stays connected inside a tenant's subset. ## Where it breaks down Several situations push back toward separate collections: - **Skew.** One tenant holds 80% of the points. Their queries and their index maintenance affect everyone else's tail latency. A dedicated collection for the whale, payload partitioning for the long tail, is a legitimate hybrid. - **Divergent configuration.** Different vector dimensions, different distance metrics, a tenant that needs quantization and one that must not — none of these are per-point settings, so they force separate collections. - **Blast radius.** A corrupted or over-optimised collection affects every tenant in it. - **Compliance.** "Prove this customer's data is not stored with anyone else's" is answerable structurally and awkward to answer with a filter. - **Deletion economics.** Removing a tenant from a shared collection is `delete(points_selector=models.FilterSelector(filter=...))`, which is cheap to issue but leaves the segments to be reclaimed by the optimizer in the background. Dropping a collection is instantaneous and complete — a real advantage when you have a contractual deletion deadline. ## The security question This is the part that separates a considered answer from a shallow one. Under payload partitioning, tenant isolation is a **property of every query your application issues**. One forgotten filter — a new endpoint, a debugging script, an analytics job, a `scroll` call written in a hurry — returns other tenants' data with no error and no signal. Structural isolation has no equivalent failure. The mitigation is architectural, not incidental: no application code constructs a raw Qdrant request. All reads go through one repository layer that takes the tenant from the request context and injects the filter itself, with the underlying client never exposed. Then add a test that asserts a query built without a tenant context fails rather than returning everything. Also apply the filter to writes and deletes — a `set_payload` or `delete` scoped by a filter that omits the tenant field is destructive across tenants. ## Choosing A workable default: start with payload partitioning; promote a tenant to its own collection when it is large enough that its behaviour is visible to others, or when a contract demands structural separation. Design for the promotion path from day one — keep the tenant id in the payload even in dedicated collections, and route reads through a resolver that maps tenant to collection, so moving one tenant is a data migration rather than a code rewrite. What you should never do is choose per-tenant collections "for safety" without counting them. Thousands of near-empty collections is a memory and operations problem that arrives quietly and is expensive to undo.

  • How do you delete one tenant's data from a shared collection, and how does that compare to dropping a collection?
    Issue `delete(points_selector=models.FilterSelector(filter=...))` on the tenant filter. The points stop being visible promptly, but the space is reclaimed by the optimizer in the background rather than instantly. Dropping a dedicated collection removes everything at once. When a contract sets a hard deletion deadline with evidence requirements, that difference is often what decides the architecture.
  • What would make you promote a single tenant out of the shared collection?
    Size skew that makes their queries and index maintenance visible in everyone else's latency; a configuration need that is per-collection rather than per-point, such as a different vector dimension or quantization setting; or a contractual demand for physical separation. Design for it early by keeping a tenant-to-collection resolver in the read path, so promotion is a migration rather than a rewrite.
  • How do you stop a new code path from forgetting the tenant filter?
    Do not let application code hold the Qdrant client. Wrap every read and write in a repository that takes the tenant from request context and builds the filter itself, and make the tenant argument non-optional so omitting it fails at construction. Back it with a test asserting that a query without tenant context raises instead of returning cross-tenant results.

saying these in an interview costs you the question

  • Creating a collection per tenant without counting the tenants
  • Assuming Qdrant enforces tenant isolation on its own
  • Filtering by tenant on reads but not on deletes or payload updates
  • Treating a skewed whale tenant like every other tenant
  • Believing filter-based deletion frees storage immediately

context