# turbopuffer compatibility

This page describes a compatibility layer for the turbopuffer SDKs. WarpSearch's own API under `/v1` is the primary way to create collections and define [schemas](schemas-and-fields.md).

WarpSearch serves the turbopuffer HTTP API at `https://api.warpsearch.io/compat/turbopuffer`. The official turbopuffer SDKs for Python, TypeScript, Go, Java and Ruby work against it by changing configuration only: a base URL and a WarpSearch API key. Requests and responses keep turbopuffer's shapes, including `$dist`, `rows`, `aggregations` and multi-query `results`.

A turbopuffer namespace is a WarpSearch collection, so the same data can also be searched with the Elasticsearch, OpenSearch, Solr, SQL and MCP interfaces. Features that are not implemented yet fail with `501` and nothing is written, so you never get a silently wrong answer.

## Connect

Create an API key in the [management console](/console/) and use it as the turbopuffer API key. The SDKs send it as `Authorization: Bearer ws_live_...`, which WarpSearch accepts.

| SDK | Base URL setting | API key |
|---|---|---|
| Python | `Turbopuffer(base_url=...)` or `TURBOPUFFER_BASE_URL` | `api_key=` or `TURBOPUFFER_API_KEY` |
| TypeScript | `new Turbopuffer({ baseURL })` or `TURBOPUFFER_BASE_URL` | `apiKey` or `TURBOPUFFER_API_KEY` |
| Go | `option.WithBaseURL(...)` or `TURBOPUFFER_BASE_URL` | `option.WithAPIKey(...)` or `TURBOPUFFER_API_KEY` |
| Java | `.baseUrl(...)` on the client builder, `TURBOPUFFER_BASE_URL` or the `turbopuffer.baseUrl` system property | `.apiKey(...)` or `TURBOPUFFER_API_KEY` |
| Ruby | `base_url:` or `TURBOPUFFER_BASE_URL` | `api_key:` or `TURBOPUFFER_API_KEY` |

> **Remove the region.** Every SDK refuses to start with `region is set, but would be ignored` when a region is configured and the base URL has no `{region}` placeholder. Delete the `region` argument and unset `TURBOPUFFER_REGION`.

```diff
  from turbopuffer import Turbopuffer

  tpuf = Turbopuffer(
-     region="gcp-us-central1",
-     api_key="tpuf_...",
+     base_url="https://api.warpsearch.io/compat/turbopuffer",
+     api_key="ws_live_...",
  )
```

```ts
import Turbopuffer from '@turbopuffer/turbopuffer';

const tpuf = new Turbopuffer({
  baseURL: 'https://api.warpsearch.io/compat/turbopuffer',
  apiKey: process.env['TURBOPUFFER_API_KEY'], // a ws_live_... key
});
```

If your code reads its settings from the environment, switching needs no code change at all:

```bash
export TURBOPUFFER_BASE_URL=https://api.warpsearch.io/compat/turbopuffer
export TURBOPUFFER_API_KEY=ws_live_...
unset TURBOPUFFER_REGION
```

Older or community clients that cannot change the URL can usually take a custom HTTP client (an httpx client in Python, a custom `fetch` in TypeScript, `option.WithHTTPClient` or `option.WithMiddleware` in Go) that rewrites the request host. Leave SDK compression at its default. The `X-turbopuffer-Client-Telemetry` header is accepted and ignored.

## Namespaces and collections

A namespace whose name is a valid WarpSearch collection name (1 to 64 characters, starting with a lowercase letter, only lowercase letters, digits and `_`, no `__`) uses that name directly. Any other turbopuffer name, with uppercase letters, `-` or `.` and up to 128 characters, is stored under a derived collection name: `tp_` followed by 26 lowercase letters and digits. The turbopuffer API keeps using the original name everywhere, in writes, queries and the namespace list. The console shows the derived collection, with the original name in its `turbopuffer_namespace` metadata.

The first write creates the namespace, as does `create_namespace: true`. Creating one needs a key with the `editor` or `admin` role. Writes need `writer`, queries need `viewer`, and deleting a namespace needs `admin`. See [API keys, roles and rate limits](access-control.md). For a derived `tp_` name, a key limited to certain collection names must also cover the `tp_` name. Plan limits on collections, request sizes, documents per request and rates apply as on every other API; see [Limits and errors](limits-and-errors.md).

## Endpoints

| Endpoint | Notes |
|---|---|
| `POST /v2/namespaces/{namespace}` | Write |
| `POST /v2/namespaces/{namespace}/query` | Query and multi-query |
| `DELETE /v2/namespaces/{namespace}` | Returns `{"status":"OK"}`. Data removal finishes in the background, and the name stays taken until then |
| `GET /v1/namespaces` | `prefix`, `cursor`, `page_size`. Returns `{"namespaces":[{"id":...}], "next_cursor":...}` |
| `GET /v2/namespaces/{namespace}/metadata` | `approx_row_count`, `approx_logical_bytes`, `created_at`, `updated_at`, `schema`. Row and byte counts are refreshed about once a minute and read 0 until first measured. Index status is always up to date, encryption is the default |
| `GET`, `POST /v1/namespaces/{namespace}/schema` | Read, or add attributes. Changing an existing attribute's type fails with `400`, as in turbopuffer; changing its other options (for example turning on `full_text_search`) answers `501` |
| `GET /v1/namespaces/{namespace}/hint_cache_warm` | Loads an idle namespace onto a search node ahead of traffic. Returns `{"status":"ACCEPTED"}`. See [Idle collections and cold starts](collections.md#idle-collections-and-cold-starts) |

> **Coming soon:** `explain_query`, `_debug/recall`, `copy_from_namespace`, `branch_from_namespace` and the async copy operations, and `PATCH` on namespace metadata (pinning and `read_only`). They answer `501` today.

## Writes

Supported: `upsert_rows`, `upsert_columns`, `deletes`, `distance_metric` (`cosine_distance` or `euclidean_squared`), `schema`, `create_namespace` and `return_affected_ids`. `disable_backpressure` and `sharding` are accepted and ignored.

A write is committed before the response returns, so the next query sees it. Queries accept `consistency: {"level": "eventual"}` as well. Every upsert replaces the whole document, as in turbopuffer. IDs can be unsigned integers, UUIDs or strings of up to 64 bytes, and come back with the type you wrote.

> **Coming soon:** `patch_rows`, `patch_columns`, `patch_by_filter`, `delete_by_filter`, `upsert_condition`, `patch_condition`, `delete_condition`, `encryption`, `copy_from_namespace` and `branch_from_namespace`. A request that contains any of them is rejected with `501` and nothing is written, even if it also contains upserts.

`501` responses carry `x-should-retry: false`, so the SDKs raise at once instead of retrying. Errors use the body `{"status":"error","error":"<message>"}`. Authentication failures (`401`) and rate limiting (`429`, with `Retry-After`) use WarpSearch's [standard error body](limits-and-errors.md); the SDKs act on the status code either way.

## Schema and types

Types are inferred from the first non-null value written, or declared in `schema`. An attribute that has only ever been `null` is not created yet. Supported: `string`, `int`, `uint` (up to 2^63-1), `float`, `uuid`, `datetime`, `bool`, their array forms (`[]string`, `[]int`, `[]uint`, `[]float`, `[]uuid`, `[]datetime`, `[]bool`), and dense vectors `[N]f32` and `[N]f16`. `f16` vectors are stored at half precision; on the wire they stay float32.

Vectors can be sent as JSON number arrays or as base64 little-endian float32. Set `vector_encoding: "base64"` on a query to get them back in base64. A column named `vector` is a vector automatically. Other vector attributes need a `schema` entry with an `[N]f32` type or `ann: true`. Every vector attribute gets an approximate index, built automatically as the namespace grows.

`full_text_search: true`, or a configuration object, makes a string attribute searchable with BM25.

| Option | Support |
|---|---|
| `case_sensitive`, `ascii_folding` | Honored |
| `stemming`, `language` | English only. WarpSearch uses a dictionary stemmer (KStem), so some stems differ from turbopuffer's |
| `remove_stopwords` | Accepted, no effect. Stop words are kept, so scores differ slightly |
| `tokenizer`, `max_token_length`, `k1`, `b` | Accepted, no effect |
| `filterable`, `regex`, `glob`, `fuzzy` | Accepted. Filters work on every non-text attribute whatever these say |

On a full-text attribute, `Eq` matches as a phrase rather than by exact value, unless the attribute also has `filterable: true`.

Attribute names may use letters, digits, `_`, `.` and `@`, and must start with a letter or `_`; other names (for example with `-` or spaces) fail with `400`. Attributes created through other WarpSearch APIs, such as the WarpSearch schema API or an Elasticsearch mapping, appear in the turbopuffer schema with the closest type.

> **Not supported:** multi-vector `[]N f32` with `late_interaction`, sparse vectors (`{}f16` and `sparse_knn`), `bytes`, `[N]i8`, and `embed`. They fail with `400` or `501`.

## Queries

| Feature | Support |
|---|---|
| `rank_by` vector | `["vector", "ANN", [...]]` and exact `"kNN"` |
| `rank_by` full text | `["text", "BM25", "query"]`, with `last_as_prefix`. `Sum`, `Max` and `Product` (weights) over BM25 clauses, and filters inside `rank_by` (score 1 when they match) |
| `rank_by` attributes | `["price", "desc"]`, up to 8 keys, and ordering by `id` |
| `filters` | `And`, `Or`, `Not`, `Eq`, `NotEq`, `In`, `NotIn`, `Lt`, `Lte`, `Gt`, `Gte`, `Contains`, `NotContains`, `ContainsAny`, `NotContainsAny`, `AnyLt`, `AnyLte`, `AnyGt`, `AnyGte`, `Glob` and `NotGlob` (`*` and `?` only), `Regex`, `Fuzzy`, `ContainsAllTokens`, `ContainsAnyToken`, `ContainsTokenSequence` |
| Results | `include_attributes` (a list or `true`; unknown names are ignored), `exclude_attributes`, `top_k` (default 10), `limit` (a number or `{"total": n}`), `offset`, `vector_encoding`, `consistency` |
| Diversification | `limit.per` with `limit: 1` and one attribute keeps the best row per value |
| `aggregate_by` | `Count` and `Sum`, with or without `group_by` on one or more attributes, and `["ForEachUnique", "tags"]` for arrays. Documents without a value for a group key are left out of the groups |
| Multi-query | `queries` (up to 16), and `rerank_by: ["RRF", {"rank_constant": 60, "weights": [...]}]` for one fused hybrid result, paged by a top-level `limit` and `offset` |

`$dist` is the vector distance for ANN and kNN (`cosine_distance` is 1 minus cosine similarity), the BM25 score for full text, and the RRF score after `rerank_by`. It is absent when you order by attributes. Documents without a value for the order attribute come last in both directions (turbopuffer puts them first when ascending). Null handling follows turbopuffer: `Eq null` matches documents without the attribute, `NotEq null` those with it, and `Lt` and `Lte` also match documents without it.

Responses have `rows`, `aggregations`, `aggregation_groups`, `results` for multi-query, `billing` (byte counts are always zero, since WarpSearch bills by [its own plans](pricing.md)) and `performance` (`server_total_ms`, `query_execution_ms`; cache fields report hot and 1.0).

> **Coming soon:** the `Attribute`, `Saturate`, `Decay` and `Dist` rank expressions, `SparseKNN`, `Embed` in `rank_by`, `compute_attributes` (including `Highlight`), `rerank_by` with a model, `IGlob` and `NotIGlob`, `Glob` with `[...]` classes or `{a,b}` alternatives, and `limit.per` with several attributes or more than one row per value. They answer `501`.

## Example

```python
from turbopuffer import Turbopuffer

tpuf = Turbopuffer(
    base_url="https://api.warpsearch.io/compat/turbopuffer",
    api_key="ws_live_...",
)
ns = tpuf.namespace("docs")

ns.write(
    upsert_rows=[
        {"id": 1, "vector": [0.1, 0.9, 0.2], "text": "Fast hybrid search", "category": "guide"},
        {"id": 2, "vector": [0.8, 0.1, 0.3], "text": "Billing and plans", "category": "faq"},
    ],
    distance_metric="cosine_distance",
    schema={"text": {"type": "string", "full_text_search": True}},
)

ann = ns.query(
    rank_by=("vector", "ANN", [0.2, 0.8, 0.1]),
    filters=("category", "Eq", "guide"),
    top_k=10,
    include_attributes=["text"],
)
for row in ann.rows:
    print(row.id, row["$dist"], row["text"])

bm25 = ns.query(rank_by=("text", "BM25", "hybrid search"), top_k=10)

hybrid = ns.multi_query(
    queries=[
        {"rank_by": ("vector", "ANN", [0.2, 0.8, 0.1]), "top_k": 20},
        {"rank_by": ("text", "BM25", "hybrid search"), "top_k": 20},
    ],
    rerank_by=("RRF", {"rank_constant": 60}),
    limit=10,
)
print(hybrid.results[0].rows)
```

The same calls over HTTP:

```bash
curl -s -X POST https://api.warpsearch.io/compat/turbopuffer/v2/namespaces/docs \
  -H "Authorization: Bearer ws_live_..." \
  -H "Content-Type: application/json" \
  -d '{"upsert_rows": [{"id": 1, "vector": [0.1, 0.9, 0.2], "text": "Fast hybrid search"}],
       "distance_metric": "cosine_distance",
       "schema": {"text": {"type": "string", "full_text_search": true}}}'

curl -s -X POST https://api.warpsearch.io/compat/turbopuffer/v2/namespaces/docs/query \
  -H "Authorization: Bearer ws_live_..." \
  -H "Content-Type: application/json" \
  -d '{"rank_by": ["text", "BM25", "hybrid"], "top_k": 10, "include_attributes": ["text"]}'
```

## Differences from turbopuffer

| Area | WarpSearch |
|---|---|
| Region | None. Remove `region` and `TURBOPUFFER_REGION` |
| Consistency | Writes are visible to the next query |
| BM25 scores | Different tokenizer from `word_v4`, stop words kept, KStem stemming. Same matching rows, different `$dist` and occasionally a different order for close scores |
| Vector distances | Same as turbopuffer |
| Multi-query without `rerank_by` | Each subquery runs separately, not on one snapshot |
| Ordering by attributes | Missing values sort last in both directions |
| Attribute names | Letters, digits, `_`, `.` and `@` only |
| Conditional writes, patches, delete by filter | Not yet, `501` |
| Sparse and multi-vector, `embed` | Not supported |
| Billing fields | Always zero; WarpSearch bills by its own plans |

To search the same collection with other interfaces, see [Searching](searching.md), [Query languages](query-languages.md), [Vector search](vector-search.md) and [Hybrid search](hybrid-search.md). For limits and error codes, see [Limits and errors](limits-and-errors.md).
