Symptoms
- Ingestion jobs log
429 Too Many RequestsorRESOURCE_EXHAUSTED(gRPC). 400 Bad Requestwith a message about request or message size.- A loader that upserts one record per request runs for hours.
- Retries pile up and the job never catches up with new documents.
Step 1: find which limit you hit
| Error | Limit | Fix |
|---|---|---|
| Request size / message too large | Per-request size (about 2 MB) and record count (1,000 per upsert) | Smaller batches; trim metadata |
| Metadata too large for record | Metadata per record (40 KB) | Don't store full chunk text in metadata if it's huge; store a pointer |
| 429 on writes | Write throughput limits per index/namespace and plan | Fewer parallel workers, backoff, or bulk import |
| 429 on reads during load | Read limits; load competes with live queries | Run loads off-peak or into a separate index, then switch |
Note
Pinecone publishes its limits per plan and changes them over time. Read the current "Limits" page for your plan instead of hard-coding numbers from a blog post.
Log the full response body; it usually names the exact limit.
Step 2: batch properly
One record per request wastes most of the time on network round-trips. Batch by count and size:
from pinecone import Pinecone
import json
pc = Pinecone(api_key=API_KEY)
index = pc.Index(host=INDEX_HOST) # use the host from describe_index, not a name lookup per call
MAX_RECORDS = 200
MAX_BYTES = 1_500_000 # stay under the request size limit with margin
def batches(records):
batch, size = [], 0
for r in records:
r_size = len(json.dumps(r).encode())
if batch and (len(batch) >= MAX_RECORDS or size + r_size > MAX_BYTES):
yield batch
batch, size = [], 0
batch.append(r)
size += r_size
if batch:
yield batch
for b in batches(records): # records: [{"id":..., "values":[...], "metadata":{...}}]
index.upsert(vectors=b, namespace="kb-prod")
With 1536-dimension float vectors, each record is roughly 6 KB of values as binary but much more as JSON, so the size limit, not the 1,000-record limit, is usually the one that applies. The gRPC client (pip install "pinecone[grpc]") sends vectors more compactly than REST JSON.
Step 3: parallelise, within limits
from pinecone.grpc import PineconeGRPC
pc = PineconeGRPC(api_key=API_KEY)
index = pc.Index(host=INDEX_HOST)
futures = [index.upsert(vectors=b, namespace="kb-prod", async_req=True) for b in batches(records)]
for f in futures:
f.result() # raises if a batch failed
Start with a small number of concurrent requests (4–8), measure records per second and 429 rate, then raise slowly. More parallelism past the throughput limit only produces more 429s.
Step 4: retry with backoff and jitter
import random, time
def upsert_with_retry(batch, attempts=6):
for i in range(attempts):
try:
return index.upsert(vectors=batch, namespace="kb-prod")
except Exception as e:
if "429" not in str(e) and "RESOURCE_EXHAUSTED" not in str(e) and i > 0:
raise
time.sleep(min(30, (2 ** i)) + random.random())
raise RuntimeError("upsert failed after retries")
Upserts are idempotent by ID: re-sending the same batch overwrites the same records. That makes retries safe, as long as IDs are deterministic (e.g. doc_id#chunk_no, not random UUIDs generated per attempt).
Step 5: use bulk import for large initial loads
For millions of records, serverless indexes support importing Parquet files from object storage (S3, and other providers depending on current support). It runs as an asynchronous job on Pinecone's side and doesn't consume your upsert request quota in the same way.
op = index.start_import(
uri="s3://my-bucket/embeddings/kb-prod/",
integration_id=STORAGE_INTEGRATION_ID, # storage integration configured in the console
error_mode="CONTINUE",
)
print(index.describe_import(id=op.id))
Prepare files in the documented Parquet schema (id, values, optional metadata as JSON), with one directory per namespace. Check the import documentation for the exact layout and size limits before generating files.
| Load size | Approach |
|---|---|
| Up to tens of thousands of records | Batched upserts |
| Hundreds of thousands to a few million, recurring | Parallel gRPC upserts with backoff |
| Initial load or full re-embed of millions | Bulk import from object storage |
Step 6: don't fight live traffic
If the index also serves production queries, a full reload competes with them. Options:
- Load into a new index, verify counts and recall, then switch the application's index host.
- Throttle the loader during business hours.
- Use a separate namespace and switch the namespace name in config after the load.
Step 7: verify the load
stats = index.describe_index_stats()
print(stats.total_vector_count, stats.namespaces.get("kb-prod"))
Counts are eventually consistent and can trail recent writes briefly. Compare with your source count after the loader finishes and a short wait, and spot-check with index.fetch(ids=[...], namespace="kb-prod").
Prevent it next time
- Use deterministic record IDs so every retry and re-run is safe.
- Keep metadata small: filterable fields only, plus a pointer to the chunk text if it's large.
- Put batch size, concurrency and backoff in configuration, not code.
- Use bulk import for any load over a million records.
- Alert on 429 rate in the ingestion job, not only on job failure.