Blobs
Blobs store opaque bytes under their sha256 and serve them through the
gateway. A row never stores the bytes themselves. It stores an ordinary string
attribute such as image_blob: "blob://products/<sha256>".
Use blobs for media, source files or other binary payloads that rows point at but that should not ride the document write path.
Routes
| Route | Method | Behavior |
|---|---|---|
PUT /v1/namespaces/{ns}/blobs | PUT | Store raw bytes by sha256 and return a blob:// reference. |
GET /v1/namespaces/{ns}/blobs/{sha256} | GET | Serve the bytes by sha256. |
POST /v1/namespaces/{ns}/blobs/move_to_s3 | POST | Move a store-held blob set to S3. See Move a blob set to S3. |
Where the bytes live
Blobs go to the S3-compatible object store set by S3_BUCKET, or to the
namespace’s store when it has a native binary type. The routes and the
blob:// reference are the same whichever backend holds the bytes.
LAYER_BLOB_STORAGE on the gateway chooses between them for stores that
could hold the bytes themselves:
LAYER_BLOB_STORAGE | Blobs a native store could hold |
|---|---|
auto (default) | S3 on turbopuffer namespaces when S3_BUCKET is set; the store otherwise. |
s3 | S3 whenever S3_BUCKET is set. |
store | The store, up to its per-value cap. |
turbopuffer namespaces default to S3 because turbopuffer bills each blob read as a query, by the size of the whole blob namespace rather than the blob returned. Reading many blobs from a large blob namespace multiplies that cost. An S3 read is billed per request and by the bytes it returns.
Blobs larger than the store’s per-value cap, and every blob on a store without native bytes, go to S3 under every setting.
| Store | Backend | Largest blob in the store |
|---|---|---|
| turbopuffer | A {ns}__hevlayer_blobs namespace, one row per sha256 with a bytes attribute | 6 MiB |
| Postgres (pgvector) | The layer_pgvector.blobs table, one bytea row per sha256 | 10 MiB (the gateway cap) |
| Stores without native bytes | S3 | None; every blob goes to S3 |
The gateway accepts blobs up to 10 MiB. turbopuffer caps a bytes value at
8 MiB of base64 on the wire, which is 6 MiB of blob. A blob between 6 MiB and
10 MiB on turbopuffer goes to S3.
With no S3_BUCKET, a blob that does not fit in the store is rejected rather
than dropped:
| Case | Status | error |
|---|---|---|
| Blob over 10 MiB | 413 | payload_too_large |
| Blob over the store’s cap, no S3 | 413 | blob_exceeds_store_cap |
| Store without native bytes, no S3 | 422 | object_store_not_configured |
A turbopuffer blob namespace appears in namespace listings next to the namespace it serves, and deleting a namespace does not delete its blobs. The per-store values are in the store capability matrix.
Move a blob set to S3
A namespace that switched to S3 still has the blobs it wrote to its turbopuffer blob namespace. Reads find them there until they move. A move copies the blob namespace to S3 in pages of up to 10,000 blobs, one turbopuffer query per page and never one per blob:
curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/move_to_s3" \
-H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"max_pages": 1}'
{
"namespace": "products",
"done": false,
"cursor": "7d1a54127b222502f5b79b5fb0803061...",
"pages": 1,
"moved": 9987,
"already_in_s3": 13,
"mismatched": 0,
"moved_bytes": 2147483648,
"scanned_bytes": 2150215680,
"page_size": 10000,
"billable_bytes_queried": 10737418240,
"billable_bytes_returned": 2863311530,
"updated_at": "2026-10-13T18:04:11Z",
"run": {
"pages": 1,
"moved": 9987,
"already_in_s3": 13,
"mismatched": 0,
"moved_bytes": 2147483648,
"scanned_bytes": 2150215680,
"billable_bytes_queried": 10737418240,
"billable_bytes_returned": 2863311530
}
}
Each call moves up to max_pages pages (default 1) and returns. Call it
again until done is true. The top-level counters are totals for the
whole move; run is what this call did. billable_bytes_queried and
billable_bytes_returned are what turbopuffer billed for the move’s queries.
| Field | Default | Meaning |
|---|---|---|
page_size | Sized by bytes | Blobs per turbopuffer query, 1 to 10,000. Unset, the first page is 100 blobs and later pages hold about 256 MiB of blobs at the mean size seen so far, up to 10,000. The gateway holds a whole page in memory. |
max_pages | 1 | Pages this call moves before returning. |
restart | false | Start again from the first blob, even after the move finished. |
A move is safe to rerun or interrupt. Progress is saved in the object store
after every page, so the next call starts after the last page saved. A blob
already in S3 is not rewritten. A row whose bytes do not hash to its sha256
is counted in mismatched and not copied. The move is done when a page
comes back empty, so the last call makes one query that finds nothing. Once
done is true, further calls return the totals without querying
turbopuffer.
A finished move changes reads. Before it finishes, a blob missing from S3 is looked up in the turbopuffer blob namespace. After it finishes, S3 is the only backend, and a blob missing from S3 is a 404 with no turbopuffer query. The move leaves the turbopuffer blob namespace in place. Delete it yourself once reads are served from S3.
A blob written to the store after a move finished (because the namespace
switched back to store, or a gateway had not picked up the switch yet)
reopens the move: S3 misses ask the store again, and the next call copies
the new blob. Deleting a namespace, or branching onto its name, clears its
move.
The namespace’s blob storage must already resolve to S3: the route returns
409 when it resolves to the store, and 422 object_store_not_configured
without S3_BUCKET.
Store
with open("image.jpg", "rb") as f:
stored = await client.put_blob("products", f.read())
print(stored.ref)body, _ := os.ReadFile("image.jpg")
stored, err := client.PutBlob(ctx, "products", body, nil)import fs from "node:fs/promises";
const bytes = await fs.readFile("image.jpg");
const stored = await client.putBlob("products", bytes);curl -X PUT "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs" \
-H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary @image.jpg Response:
{
"ref": "blob://products/9f86d081884c7d659a2feaa0c55ad015...",
"sha256": "9f86d081884c7d659a2feaa0c55ad015...",
"size": 48213
}
The same bytes always return the same reference. The route rejects empty bodies and bodies over 10 MiB.
Write the returned ref as a normal row attribute:
{
"id": "B0123",
"vector": [0.1, 0.2],
"image_blob": "blob://products/9f86d081884c7d659a2feaa0c55ad015..."
}
The removed document blobs payload shape is still rejected. Binary bytes
do not traverse /v2/namespaces/{ns} writes.
Fetch
image = await client.get_blob("products", stored.sha256)image, err := client.GetBlob(ctx, "products", stored.Sha256)const image = await client.getBlob("products", stored.sha256);curl "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/$SHA256" \
-H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
-o image.jpg Successful responses include immutable cache headers:
Cache-Control: public, max-age=31536000, immutable
ETag: "<sha256>"
The gateway sniffs common image types (jpeg, png, gif, webp) for
Content-Type; otherwise it returns application/octet-stream.
Reads
A namespace that keeps blobs in S3 reads S3 first, then its store for blobs not yet moved. A namespace that keeps blobs in its store reads the store first, then S3, so blobs written to S3 before the store held bytes still serve.