Migrating to V10

The Pinecone 2026-07 API is what the 10.x Python SDK speaks. This guide covers what changed for SDK users moving from 9.x, organized by what you’re doing rather than by which surface happened to change.

Where to start: if you were on pc.preview.*, start with Preview namespace removed: the preview surface is gone outright, with no shim and no deprecation window. If you were on the stable 9.x surface (pc.create_index(dimension=..., metric=..., spec=...)), skip that section and start with Index creation and configuration.

Contents

Documents API

Preview graduated into this release, and every 2026-07 index (dense, sparse, hybrid, or full-text-search) is created with a schema and served through the documents API. This section covers migrating off preview and what changed about creating and configuring an index.

Preview namespace removed

The 2026-01.alpha preview surface graduated. Everything that lived under pc.preview is now a first-class part of the SDK, and the pinecone/preview/ package is deleted outright. There’s no shim and no deprecation window. Preview was never covered by SemVer, and its docstrings said so.

Every stale preview import or attribute access raises immediately, at the point of use, so the migration is mechanical to find:

Stale code

Now raises

import pinecone.preview

ModuleNotFoundError

from pinecone.preview.models import PreviewIndexModel

ModuleNotFoundError

pc.preview on Pinecone or AsyncPinecone

AttributeError

from pinecone import PreviewIndexModel (or any Preview* name)

ImportError

Entry points

The graduated surface hangs off the client directly. pc.preview was one extra hop; delete it and the rest of the expression is almost unchanged.

Removed

Replacement

pc.preview.indexes

pc.indexes

pc.preview.index(name=...) / (host=...)

pc.index(name=...) / (host=...), or positionally pc.index("my-index")

pc.preview.index(...).documents.upsert(...)

pc.index(...).documents.upsert(...)

pc.preview.close()

pc.close()

from pinecone.preview import SchemaBuilder

from pinecone import SchemaBuilder

from pinecone import Pinecone

pc = Pinecone(api_key="your-api-key")

# was: pc.preview.indexes.describe("articles-en-preview")
info = pc.indexes.describe("articles-en")

# was: index = pc.preview.index(name="articles-en-preview")
with pc.index(name="articles-en") as index:
    result = index.documents.fetch(namespace="articles-en", ids=["doc-1"])
await pc.index(...) is now a coroutine

AsyncPreview.index() was synchronous: it handed back an index immediately and resolved the host lazily, on the first data-plane call. AsyncPinecone.index() resolves eagerly and must be awaited.

import asyncio
from pinecone import AsyncPinecone


async def main() -> None:
    pc = AsyncPinecone(api_key="your-api-key")
    # was: index = pc.preview.index(name="articles-en-preview")
    index = await pc.index(name="articles-en")
    async with index:
        await index.documents.fetch(namespace="articles-en", ids=["doc-1"])
    await pc.close()


asyncio.run(main())

Two consequences follow from that:

A bad index name now fails at pc.index(...), not on the first data-plane call. If you were catching NotFoundError around your first search/fetch, move the try up to the await pc.index(...). And pc.index(...) can’t be called outside a running event loop, so module-level index = pc.preview.index(name=...) no longer has an equivalent.

Targeting by host= still skips the control-plane round trip, so await pc.index(host=...) never raises NotFoundError. The await is cheap there, but still required.

Model imports

Every Preview* model dropped its prefix and moved out of pinecone.preview.models. All of the replacements are importable from pinecone and from pinecone.models.

Straight renames

Find and replace:

Removed

Replacement

PreviewIndexModel

IndexModel

PreviewIndexStatus

IndexStatus

PreviewSchema

IndexSchema

PreviewSchemaField

IndexSchemaField

PreviewDenseVectorField

DenseVectorField

PreviewStringField

StringField

PreviewStringListField

StringListField

PreviewBooleanField

BooleanField

PreviewDeployment

IndexDeployment

PreviewManagedDeployment

ManagedDeployment

PreviewReadCapacity

ReadCapacityResponse

PreviewReadCapacityStatus

ReadCapacityStatus

PreviewReadCapacityOnDemandResponse

ReadCapacityOnDemandResponse

PreviewReadCapacityDedicatedResponse

ReadCapacityDedicatedResponse

PreviewReadCapacityManualScaling

ScalingConfigManual

PreviewCreateIndexRequest

CreateIndexRequest

PreviewConfigureIndexRequest

ConfigureIndexRequest

PreviewSparseValues

SparseValues

PreviewDocument

Document

PreviewDocumentUpsertResponse

UpsertDocumentsResponse

PreviewTextQuery

TextQuery

PreviewDenseVectorQuery

DenseVectorQuery

PreviewSparseVectorQuery

SparseVectorQuery

PreviewScoreByQuery

DocumentScoringMethod

PreviewSparseValuesSparseValues and PreviewIndexStatusIndexStatus each gained dict-style access (status["ready"] alongside status.ready). Nothing was taken away.

from pinecone import (
    BooleanField,
    DenseVectorField,
    DenseVectorQuery,
    Document,
    DocumentScoringMethod,
    IndexDeployment,
    IndexModel,
    IndexSchema,
    IndexSchemaField,
    IndexStatus,
    ManagedDeployment,
    ReadCapacityResponse,
    ScalingConfigManual,
    SparseValues,
    SparseVectorQuery,
    StringField,
    StringListField,
    TextQuery,
    UpsertDocumentsResponse,
)
PreviewIntegerField and PreviewLegacyIntegerField cross over

The two preview classes carried each other’s wire tags. PreviewIntegerField was tagged "float" and PreviewLegacyIntegerField was tagged "integer". The graduated names are correct, which means a blind find-and-replace gives you the wrong type:

Removed

Wire type

Replacement

PreviewIntegerField

"float"

FloatField

PreviewLegacyIntegerField

"integer"

IntegerField

If you wrote isinstance(field, PreviewIntegerField) to find whole-number fields in a schema, that check was already matching floats. It needs to become isinstance(field, IntegerField).

Renames where the shape also changed

Removed → Replacement

What changed

PreviewDenseVectorFieldDenseVectorField

dimension and metric are required, no longer defaulted to None

PreviewSparseVectorFieldSparseVectorField

metric removed; sparse fields have no configurable metric

PreviewSemanticTextFieldSemanticTextField

model is required; dimension removed. Can’t be declared at create time on 2026-07, it appears in responses only

PreviewFullTextSearchConfigFullTextSearchConfig

lowercase and max_term_len removed; gained ngram: NgramConfig | None

PreviewPodDeploymentPodDeployment

replicas and shards are required; pods removed

PreviewByocDeploymentByocDeployment

cloud and region removed, only environment remains

PreviewQueryStringQueryQueryStringQuery

gained field and fields, so a query-string clause can be scoped to named fields

PreviewUsage → three types

split by operation, see below

PreviewDocumentSearchResponseSearchDocumentsResponse

usage is now DocumentSearchUsage

PreviewDocumentFetchResponseFetchDocumentsResponse

usage is now DocumentFetchUsage; gained pagination

PreviewUsage was one struct shared by the search and fetch responses. It’s now three, one per operation:

Response

Usage type

SearchDocumentsResponse

DocumentSearchUsage

FetchDocumentsResponse

DocumentFetchUsage

ListDocumentsResponse

DocumentListUsage

All three carry the same single read_units: int field, so response.usage.read_units is unchanged. Only a type annotation or an isinstance check needs updating.

New model names with no preview predecessor: FloatField, LegacyMetadataField, NgramConfig, FullTextSearchConfig (as a top-level export), DocumentRecord, UpdateDocumentRecord, ListedDocumentRecord, DeleteDocumentsResponse, UpdateDocumentsResponse, ListDocumentsResponse, DocumentSearchUsage, DocumentFetchUsage, DocumentListUsage, and the six *DocumentsRequest structs. PreviewBackupModel and PreviewCreateBackupRequest also disappear with the package; see backup models for that mapping.

The .documents namespace

pc.preview.index(...) returned a wrapper whose only job was to hold a .documents proxy. Graduating out of preview retired the wrapper, and the .documents namespace carries over as the surface for document operations: index.documents is a lazily-instantiated property, same as every other resource namespace on this SDK (pc.indexes, pc.inference, and so on).

Preview

Now

index.documents.upsert(...)

index.documents.upsert(...)

index.documents.batch_upsert(...) (new)

index.documents.search(...)

index.documents.search(...)

index.documents.fetch(...)

index.documents.fetch(...)

index.documents.delete(...)

index.documents.delete(...)

index.documents.update(...) (new)

index.documents.list(...) (new)

Every keyword argument preview accepted is still accepted under the same name, so for most calls only the access path changes. AsyncIndex.documents mirrors all seven; documents.list(...) isn’t a coroutine on either lane, it returns a paginator.

from pinecone import Pinecone

pc = Pinecone(api_key="your-api-key")
with pc.index(name="articles-en") as index:
    index.documents.upsert(
        namespace="articles-en",
        documents=[{"_id": "doc-1", "title": "Rome", "views": 12}],
    )

    hits = index.documents.search(
        namespace="articles-en",
        top_k=10,
        score_by=[{"type": "text", "query": "roman aqueducts", "field": "title"}],
    )
    for match in hits.matches:
        print(match.id, match.title)
import asyncio
from pinecone import AsyncPinecone


async def main() -> None:
    pc = AsyncPinecone(api_key="your-api-key")
    index = await pc.index(name="articles-en")
    async with index:
        await index.documents.upsert(
            namespace="articles-en",
            documents=[{"_id": "doc-1", "title": "Rome", "views": 12}],
        )
        hits = await index.documents.search(
            namespace="articles-en",
            top_k=10,
            score_by=[{"type": "text", "query": "roman aqueducts", "field": "title"}],
        )
        for match in hits.matches:
            print(match.id, match.title)
    await pc.close()


asyncio.run(main())
documents.delete returns a response object

Preview’s index.documents.delete(...) returned None. The graduated version returns a DeleteDocumentsResponse, and it accepts a filter, which is why there’s now something to return.

# preview returned None:
#   index.documents.delete(namespace="articles-en", ids=["doc-1"])
response = index.documents.delete(namespace="articles-en", ids=["doc-1"])

# new: delete by filter, with a count of what matched
response = index.documents.delete(
    namespace="articles-en",
    filter={"views": {"$lt": 5}},
)
print(response.matched_records)

matched_records is None for an ID-list or delete_all delete. For a filtered delete it’s the point-in-time count when the server accepted the request. The delete applies asynchronously behind a 202, so it isn’t a promise about how many documents ultimately disappear. matched_records on UpdateDocumentsResponse means the same thing.

Preview’s ids / delete_all mutual exclusion still holds, widened to three options: exactly one of ids, filter, or delete_all must be given.

documents.fetch gained a filter and pagination

Preview’s fetch took ids only. documents.fetch takes exactly one of ids or filter, and a filtered fetch is paginated: a page holds up to 10000 documents and the page size is fixed.

page = index.documents.fetch(namespace="articles-en", filter={"views": {"$gt": 100}})
while True:
    for doc_id, doc in page.documents.items():
        print(doc_id, doc.title)
    if page.pagination is None:
        break
    page = index.documents.fetch(
        namespace="articles-en",
        filter={"views": {"$gt": 100}},
        pagination_token=page.pagination.next,
    )

An ID-based fetch is never paginated, so response.pagination stays None and existing fetch call sites need no loop.

Smaller signature deltas

timeout is a new keyword on every document method, None by default, matching the previous behavior. documents.batch_upsert(max_concurrency=...) no longer accepts None; it’s int = 4 now, so pass the number or omit it. documents.batch_upsert dropped **kwargs, so a typo is now a TypeError at the call site instead of being silently swallowed. documents= accepts Sequence[Mapping[...]], not just list[dict], and also accepts typed DocumentRecord / UpdateDocumentRecord objects; lists of dicts keep working.

GrpcIndex has no document operations. The 2026-07 documents surface is REST-only.

Index operations

All nine method names carry over unchanged: create, configure, describe, list, exists, delete, create_backup, list_backups, describe_backup. Deleting preview. from the attribute chain is most of the change. AsyncPinecone.indexes mirrors all nine, with list and list_backups non-coroutine on both lanes.

from pinecone import Pinecone, SchemaBuilder

pc = Pinecone(api_key="your-api-key")

schema = (
    SchemaBuilder()
    .add_dense_vector_field("embedding", dimension=1536, metric="cosine")
    .build()
)

# was: pc.preview.indexes.create(schema=..., deployment=...)
index = pc.indexes.create(
    name="articles-en",
    schema=schema,
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)
print(index.status.ready)

SchemaBuilder.build() already returns the {"fields": {...}} envelope, so pass its result straight to schema=.

For a preview caller, four things changed: create() now polls until the index is ready by default (timeout=-1 restores the old return-immediately behavior); create() no longer accepts source_collection or source_backup_id (the backend rejects both with a 400, use pc.create_index_from_backup(...) to restore a backup instead); create() and configure() accept a typed IndexSchema as well as a plain dict; and create_for_model() is new, replacing the old integrated-embedding path.

Everything else about the 2026-07 create/configure contract, including the schema rules and field removals, is the same for you as for a 9.x caller and is covered in Index creation and configuration below.

Newly importable from pinecone

Independent of the preview retirement, this release wired up top-level exports that were built earlier in the cycle but never reachable from pinecone or pinecone.models. If you were reaching into pinecone.models.admin.* or pinecone.models.assistant.* directly, those paths still work too.

Area

Names

Assistant operations

OperationModel, ListOperationsResponse

Admin: users

UserModel, UserList

Admin: invites

InviteModel, InviteList, InviteStatus

Admin: service accounts

ServiceAccountModel, ServiceAccountList, ServiceAccountWithSecret

Admin: role bindings

RoleBindingModel, RoleBindingList, RoleBindingInput, RoleName, PrincipalType, ResourceType

Admin: pagination

PaginationResponse

from pinecone import (
    InviteList,
    InviteModel,
    InviteStatus,
    ListOperationsResponse,
    OperationModel,
    PaginationResponse,
    PrincipalType,
    ResourceType,
    RoleBindingInput,
    RoleBindingList,
    RoleBindingModel,
    RoleName,
    SchemaBuilder,
    ServiceAccountList,
    ServiceAccountModel,
    ServiceAccountWithSecret,
    UserList,
    UserModel,
)

OperationModel pairs with the assistant file lifecycle, see Assistant. The admin RBAC models belong to pc.admin.users / .invites / .service_accounts / .role_bindings.

Index creation and configuration

The 2026-07 control plane addresses vectors by field name instead of top-level scalars, and every index carries a persisted schema. This section covers everything about creating and configuring an index: what the response model (IndexModel) looks like now, what the request shape (create, configure) looks like now, and the deprecated 9.x arguments that still work as sugar for the new shape.

Every control-plane request (indexes, collections, backups, backup schedules, restore jobs) now sends X-Pinecone-Api-Version: 2026-07.

What changed on IndexModel

IndexModel no longer has .spec, .embed, or .created_at. Accessing any of them raises an AttributeError that names the replacement.

Removed

Replacement

index.spec.serverless / .pod / .byoc

index.deployment, a ManagedDeployment, PodDeployment, or ByocDeployment discriminated on deployment_type

index.spec.serverless.read_capacity

index.read_capacity (top level)

index.embed

a SemanticTextField in index.schema.fields

index.created_at

not returned by the 2026-07 API

.dimension, .metric, and .vector_type are deprecated but still work. They’re computed properties resolved from schema.fields at access time, and will be removed in a later major version.

Deprecated, still works

Replacement

index.dimension

index.schema.fields["<field>"].dimension on the DenseVectorField

index.metric

index.schema.fields["<field>"].metric on the vector field

index.vector_type

field types in index.schema.fields (DenseVectorField = dense, SparseVectorField = sparse)

New fields on IndexModel: schema, deployment, read_capacity, source_collection, source_backup_id, cmek_id. Removed exports: ServerlessSpecInfo, PodSpecInfo, ByocSpecInfo, IndexSpec, ModelIndexEmbed. New exports: IndexSchema, IndexSchemaField, DenseVectorField, SparseVectorField, SemanticTextField, StringField, StringListField, BooleanField, FloatField, IntegerField, LegacyMetadataField, FullTextSearchConfig, NgramConfig, IndexDeployment, ManagedDeployment, PodDeployment, ByocDeployment, ReadCapacityResponse, ReadCapacityOnDemandResponse, ReadCapacityDedicatedResponse, ReadCapacityDedicatedConfig, ReadCapacityStatus, ScalingConfigManual, CreateIndexRequest, ConfigureIndexRequest, IndexStatus.

A few smaller response behaviors: tags is None when an index has no tags, not {}. Schema-field description is always present and null when no description was given. Legacy metadata fields, from indexes that pre-date typed schemas, decode to LegacyMetadataField. Indexes whose schema uses a field type unknown to this SDK version are skipped by list() with a warning; describe() raises ResponseParsingError.

list() also returns a Paginator[IndexModel] instead of an IndexList now. Iteration keeps working; replace .names() with a comprehension:

names = [idx.name for idx in pc.indexes.list()]

An empty index name now raises PineconeValueError from exists() / has_index() instead of returning False.

create and configure

dimension=, metric=, vector_type=, and spec= are deprecated, keyword-only sugar on create_index(): they translate into the schema=/deployment= call below, addressing the vector by the reserved _values (dense) or _sparse_values (sparse) field name, since the SDK can’t invent the field name your own data-plane code will use. replicas=, pod_type=, and serverless_read_capacity= work the same way on configure_index(). Everything else with no faithful translation raises a PineconeTypeError whose message shows the equivalent 2026-07 call with your own values filled in.

# Deprecated sugar, produces a classic vector index served by the vectors
# API, addressing the vector by the reserved `_values` field rather than
# one you choose.
pc.create_index(
    name="movies",
    dimension=1536,
    metric="cosine",
    spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)

# Schema form: what new code should use; pick your own field name.
pc.indexes.create(
    name="movies",
    schema={"fields": {"embedding": {
        "type": "dense_vector", "dimension": 1536, "metric": "cosine"}}},
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

The old metadata schema= kwarg changed meaning entirely. The new schema= declares searched fields (dense_vector, sparse_vector, string with full_text_search). Metadata-only fields are no longer declared at create time, they’re indexed automatically at upsert, and the server rejects a 400 if you declare one. read_capacity moved to the top level; cmek_id is new. name is now optional, the server assigns one if omitted. pods= and metadata_config= have no faithful translation and raise a PineconeTypeError naming the equivalent call. Pod deployments must include all of environment, pod_type, replicas, and shards, the server rejects omissions with a 422.

source_collection= / source_backup_id= aren’t exposed: the 2026-07 backend rejects both with 400 Creating an index from collection or backup is not yet supported. Use pc.create_index_from_backup(...) to restore a backup.

Integrated-embedding creation moved from create(spec=IntegratedSpec(...)) to a dedicated pc.indexes.create_for_model(name=..., cloud=..., region=..., embed={"model": ..., "field_map": {"text": ...}}). The embedding configuration now surfaces as a semantic_text field in the returned index.schema, named after the field_map text entry, instead of index.embed.

ConfigureIndexRequest rejects an unknown PATCH field rather than ignoring it.

# Deprecated sugar, translates into deployment= below.
pc.configure_index("movies", replicas=4, pod_type="p1.x2")

# What new code should use.
pc.indexes.configure("movies", deployment={"replicas": 4, "pod_type": "p1.x2"})

embed= is removed entirely: the 9.x convert-to-integrated flow no longer exists, and the server rejects unknown PATCH fields. serverless_read_capacity= and the old BYOC-only read_capacity= collapsed into one top-level read_capacity= that covers managed and BYOC indexes. configure() now returns the updated IndexModel (it returned None in 9.x). Tags stay merge-patch, set a value to "" to delete that key.

Warning

replicas=, pod_type=, and serverless_read_capacity= still work as deprecated sugar, translated into deployment=/read_capacity=. embed= and spec= have no 2026-07 destination and raise a PineconeTypeError naming the equivalent call. Passing both a deprecated keyword and the 2026-07 argument it translates to (replicas=4 together with deployment=...) raises a PineconeValueError naming both.

Separately, read_capacity= widened its meaning. 9.x had two read-capacity keywords: serverless_read_capacity= for managed indexes, and read_capacity= for BYOC only. In 2026-07 a single top-level read_capacity= covers both. Code that passed read_capacity= intending “BYOC only” now also takes effect on managed indexes, with no error and no warning. Audit call sites that pass it before upgrading.

AsyncPinecone.indexes mirrors all of this one-for-one, with these async-visible deltas: list() (and the pc.list_indexes() shim) returns an AsyncPaginator[IndexModel] and isn’t a coroutine, so replace (await pc.indexes.list()).names() with [idx.name async for idx in pc.indexes.list()]. exists("") now raises PineconeValueError where the old async client returned False. create_for_model() is new on the async namespace. The index-scoped backup methods graduated too: create_backup() and describe_backup() are coroutines, and list_backups() returns an AsyncPaginator[BackupModel].

ServerlessSpec, PodSpec, and ByocSpec remain importable and, passed as the deprecated spec= argument, are the sugar translated into deployment=/schema= above. IntegratedSpec and EmbedConfig remain importable so the guided error can translate real values, but no create/configure path accepts them anymore.

Dense, sparse, and full-text-search examples

Pick the field name your upsert and query code will address. There’s no default, and the deprecated dimension=/spec= form can’t invent one for you.

pc.indexes.create(
    name="movies",
    schema={
        "fields": {
            "embedding": {"type": "dense_vector", "dimension": 1536, "metric": "cosine"}
        }
    },
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

A sparse_vector field takes no dimension and no metric, both of which were implied by vector_type="sparse" in 9.x:

pc.indexes.create(
    name="keywords",
    schema={"fields": {"sparse_terms": {"type": "sparse_vector"}}},
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

Warning

A hybrid index must declare a sparse_vector field explicitly. metric="dotproduct" on the dense field no longer implies sparse support on its own, and the field can’t be added later. This fails silently at create time: the index is created and only the sparse upserts are refused later. See Sparse writes require a declared field for the full explanation and what error you’ll actually see.

Full-text search has no 9.x equivalent; it existed only on the preview surface. A string field is accepted only with a full_text_search object. An empty object selects the defaults (language: "en", no stemming):

pc.indexes.create(
    name="articles",
    schema={
        "fields": {"body": {"type": "string", "full_text_search": {"language": "en", "stemming": True}}}
    },
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

stop_words: true requires stemming: true, and ngram can’t be combined with either, each is a separate 400. cmek_id is incompatible with any full_text_search field.

Configuring replicas and read capacity

# still works, deprecated, nests automatically under deployment=
pc.configure_index("movies", replicas=4, pod_type="p1.x2")

Both keys nest under deployment=, which must not carry a deployment_type key. deployment= and replicas=/pod_type= are mutually exclusive; passing both raises a PineconeValueError.

index = pc.indexes.configure("movies", deployment={"replicas": 4, "pod_type": "p1.x2"})
# still works, deprecated, managed index
pc.configure_index("movies", serverless_read_capacity={"mode": "OnDemand"})

One top-level read_capacity= now covers managed and BYOC indexes; read the warning above before upgrading code that already passes read_capacity=. read_capacity= and serverless_read_capacity= are mutually exclusive. Read capacity doesn’t apply to pod-based indexes, and changes apply asynchronously, so poll index.read_capacity.status rather than assuming the returned model is settled.

index = pc.indexes.configure(
    "movies",
    read_capacity={
        "mode": "Dedicated",
        "dedicated": {
            "node_type": "t1",
            "scaling": "Manual",
            "manual": {"shards": 2, "replicas": 2},
        },
    },
)

Create-time limits worth knowing about

None of these are checked before the request is sent, so each arrives as an ApiError carrying the server’s message verbatim.

A field description is capped at 256 bytes of UTF-8, not 256 characters, so emoji and CJK text reach the cap at a fraction of their character count. A schema may declare at most 100 full_text_search fields.

pc.indexes.create(
    name="movies",
    schema={
        "fields": {
            "embedding": {
                "type": "dense_vector",
                "dimension": 1536,
                "metric": "cosine",
                "description": "Dense embedding of the movie synopsis",
            }
        }
    },
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

full_text_search.language accepts a fixed set of 18 language codes, and stop_words is not supported for every language in that set, five are excluded. language="tr" on its own is fine; language="tr", stemming=True, stop_words=True is a 400, and the server’s message names the unsupported language by its English name rather than the code you sent. ngram doesn’t reject a language, it replaces it: a language sent alongside ngram is accepted and the created index reports en regardless of what you sent.

pc.indexes.create(
    name="articles",
    schema={
        "fields": {
            # Valid on its own: `tr` is one of the 18 `language` values.
            "body": {"type": "string", "full_text_search": {"language": "tr"}},
            # Accepted, then stored as `en`: ngram discards the language.
            "title": {
                "type": "string",
                "full_text_search": {"ngram": {"min_gram": 2, "max_gram": 4}, "language": "tr"},
            },
        }
    },
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

cmek_id has two separate checks that report differently. A cmek_id with a pod deployment, or alongside any full_text_search field, is a 400 per request. If the project enforces CMEK encryption, a pod deployment or any full_text_search field is a 412 per project, regardless of whether the request carries a cmek_id. The per-request check runs first, so a pod request that also carries cmek_id reports the 400; drop the cmek_id and the same request reports the 412 instead.

Tags stay merge-patch on create as well as configure: an empty string value means delete that key, and the 20-tag cap is checked on the merged total, not on the request alone. The SDK’s own checks run first and are narrower: tags={} is a PineconeValueError before any request (pass None to send no tags), keys must match [a-zA-Z0-9_-]{1,80}, and values must be printable ASCII within 120 characters.

# `env` is stored; `owner` is sent and then deleted by the merge, so the new
# index carries exactly one tag.
index = pc.indexes.create(
    name="movies",
    schema={"fields": {"embedding": {"type": "dense_vector", "dimension": 1536, "metric": "cosine"}}},
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
    tags={"env": "prod", "owner": ""},
)

The same rule on configure, where there’s already something to delete:

index = pc.indexes.configure("movies", tags={"team": "search", "owner": ""})

Vector data (db_data)

This covers the classic upsert/query/fetch vector operations, which keep serving indexes created before 2026-07 unchanged. Nothing here is about the documents API above.

Model and wire-format changes

The db_data vector models now follow the 2026-07 API shapes. Most of this is invisible to SDK users.

QueryRequest.queries and the QueryVector schema are gone from the API, but the SDK never exposed either name, so there’s nothing to change in code that uses Index.query(). Pass your query vector as vector= (dense), sparse_vector= (sparse), or both. If you bypassed the typed surface and sent a raw request body containing queries, the server now returns a 400; issue separate queries instead, or use query_namespaces() when the same query fans out across namespaces.

The fullness fields on describe_index_stats() are documented in camelCase now (memoryFullness, storageFullness); the SDK already decoded the wire spelling correctly, so response.memory_fullness and response.storage_fullness keep working unchanged.

Metadata values are validated before the request is sent. A metadata value must be a string, number, boolean, or list of strings:

index.upsert([("id-1", [0.1, 0.2], {
    "genre": "documentary",     # string
    "year": 2019,               # number
    "featured": True,           # boolean
    "tags": ["short", "indie"], # list of strings
})])

The server has always enforced this; what’s new is that the SDK checks it too, so a bad value raises locally instead of failing the whole batch server-side:

index.upsert([("id-1", [0.1, 0.2], {"price": {"usd": 10}})])
# PineconeTypeError: Metadata value must be a string, number, boolean or list of
# strings, got '{"usd":10.0}' for field 'price'

If you were relying on a server 400 to find bad rows, you now get a PineconeTypeError from upsert() instead. Catch PineconeError to cover both. The check runs on every input form upsert() accepts, including the gRPC path.

A None value is accepted, not rejected. The server strips null metadata values on write rather than refusing them, so this has always silently dropped the key rather than storing it:

index.upsert([("id-1", [0.1, 0.2], {"tag": None})])  # no error

This now holds on both transports. The gRPC transport used to encode a None metadata value in a way the server refused with a 400, so the same upsert succeeded over REST and failed over gRPC; now both sides silently drop the key. None inside a filter is still sent through on both transports and rejected by the server. An empty list and an empty string are also accepted.

Metadata keys may not begin with $, which is reserved for filter operators. The server rejects a $-prefixed key; the SDK doesn’t check key names client-side.

Sparse writes require a declared field

This changes no model and no method signature, which is exactly why it’s worth reading: it’s a breaking change that produces no error at the point where you have to fix it.

In 9.x, metric="dotproduct" on a dense index was the whole hybrid declaration. Nothing else had to be said, and sparse values worked. In 2026-07 that index shape doesn’t exist. Sparse traffic is gated on the schema actually declaring a sparse_vector field, so a hybrid index must declare one explicitly, and the metric buys you nothing on its own anymore.

The 2026-07 equivalent names both vector fields explicitly. Pick names your upsert and query code will address; there’s no default. dotproduct stays on the dense field, and the sparse field takes neither dimension nor metric.

# Deprecated sugar (dimension=/metric=/spec=) still accepts this 9.x-style
# call, and that is exactly the trap: it creates a dense-only schema (the
# reserved `_values` field) with no sparse_vector field, so sparse writes
# still fail silently, with no error at create time.
pc.create_index(
    name="hybrid",
    dimension=1536,
    metric="dotproduct",
    spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
pc.indexes.create(
    name="hybrid",
    schema={
        "fields": {
            "embedding": {"type": "dense_vector", "dimension": 1536, "metric": "dotproduct"},
            "sparse_terms": {"type": "sparse_vector"},
        }
    },
    deployment={"deployment_type": "managed", "cloud": "aws", "region": "us-east-1"},
)

You cannot add the sparse field afterwards. An index created without one has to be recreated, which is why this belongs in your upgrade plan rather than in a later fix.

There is no signal at create time. A create with a dense dotproduct field and no sparse field succeeds, returns a healthy IndexModel, and serves dense traffic normally. The first symptom is a refused sparse write, often from a different service and long after the upgrade. Audit for metric="dotproduct" before you upgrade; in 9.x that keyword was the hybrid declaration, so every occurrence is a candidate for a missing sparse field.

What actually fails, and with which error, is not what you’d predict. On any index you create with 2026-07, a vectors-API upsert is refused before the sparse check is ever reached, with a message that begins “This index has a document schema, so writes must go through the documents API.” Declaring the sparse field doesn’t satisfy that gate on its own; a hybrid schema is routed to the documents API rather than the vectors API. If you’re debugging a hybrid upsert against a freshly created 2026-07 index, that document-schema message is the string to search for. On the documents API, where a 2026-07 index is routed today, the same requirement shows up at search time instead, with a clearer message: Scoring field '<name>' not found in index schema.

Warning

The server’s own sparse error text is out of date. SparseNotSupported still reads “Index configuration does not support sparse values - only indexes that are sparse or using dotproduct are supported.” The “or using dotproduct” clause describes the 9.x behavior this change removed, so following that message’s advice and setting metric="dotproduct" won’t fix anything. Read it as “only indexes whose schema declares a sparse_vector field” instead.

SchemaBuilder makes the requirement discoverable: add_sparse_vector_field() sits directly beside add_dense_vector_field(), so the hybrid pair reads as a single chain and you never have to know the wire spelling.

schema = (
    SchemaBuilder()
    .add_dense_vector_field("embedding", dimension=1536, metric="dotproduct")
    .add_sparse_vector_field("sparse_terms")
    .build()
)

The chain and the dict above put identical bytes on the wire.

add_sparse_vector_field() no longer emits metric. Through 9.x the builder put {"type": "sparse_vector", "metric": "dotproduct"} on the wire; a 2026-07 sparse field has no metric at all, so the key never configured anything. It’s gone now:

# before
{"type": "sparse_vector", "metric": "dotproduct"}
# now
{"type": "sparse_vector"}

If you assert on build()’s output, drop metric from the expected sparse field. If you pass metric= or dimension= to add_sparse_vector_field(), the call now raises PineconeValueError naming the field and the key, instead of silently discarding them:

SchemaBuilder().add_sparse_vector_field("sparse_terms", metric="dotproduct")
# PineconeValueError: Field 'sparse_terms' cannot declare 'metric': a sparse
# vector field has no metric — sparse scoring is not configurable. Remove the
# argument — a sparse vector field accepts only a description.

Sparse vectors are variable-length and their scoring has no knob to turn.

Breaking changes and migration table

upsert, query, fetch, update, delete, list, and describe_index_stats serve indexes created under earlier API versions, and they’re meant to. None of them is deprecated, none is scheduled for removal, and none changed meaning on this release. If you have a workload upserting and querying an index you created before 2026-07, upgrading the SDK doesn’t change what those calls do.

What changed is index creation. A 2026-07 pc.indexes.create() always persists a document schema, and a document-schema index is addressed through the document operations, not the vector operations. So pick the family by the index you’re addressing, not by which one looks newer:

The index you’re addressing

The operations that serve it

Created under an API version earlier than 2026-07

upsert, query, fetch, fetch_by_metadata, update, delete, list, describe_index_stats

Created with 2026-07 (document schema)

documents.upsert, documents.batch_upsert, documents.search, documents.fetch, documents.update, documents.delete, documents.list

A vector-API write aimed at a document-schema index is refused, and the server’s message names the endpoint to use instead: “This index has a document schema, so writes must go through the documents API.” Read that as “wrong operation family for this index,” not a deprecation notice. See The .documents namespace for the document operations themselves.

The migration table

#

What changed

Before

Now

1

query(top_k=...) upper bound

REST and asyncio accepted any top_k >= 1; only gRPC bounded it

all three lanes accept 1-10000 and raise PineconeValueError outside it

2

describe_index_stats(filter=...)

documented as returning counts for matching vectors only

documented as rejected for every index type; there is no filtered-count operation

3

SchemaBuilder.add_boolean_field / add_float_field / add_string_list_field

filterable was left out of the field whenever it was False

filterable is always emitted, True or False

4

SchemaBuilder.add_sparse_vector_field

emitted {"type": "sparse_vector", "metric": "dotproduct"}

emits {"type": "sparse_vector"}; metric= and dimension= raise

5

Declaring a hybrid index

metric="dotproduct" on the dense field implied sparse support

the schema must declare a sparse_vector field

6

start_import(error_mode=...) omitted

documented as "continue" in two of three lanes

documented as "abort", which is what the server has always done

7

create_namespace(schema=...) omitted

read as “index every field”

inherits the index’s own metadata-index configuration

8

Enum members on the wire

mangled in inference request bodies and query strings

resolved to their values; no db_data call was affected

9

query(id=..., sparse_vector=...)

only id alongside vector was rejected client-side; id alongside sparse_vector was forwarded

id is rejected alongside either vector or sparse_vector

10

update(filter=..., values=...) / update(filter=..., sparse_values=...)

forwarded to the server

raises PineconeValueError before any request; a by-filter update cannot carry vector values

11

Empty filter={} on delete, update, fetch_by_metadata

forwarded to the server, which rejected it

raises PineconeValueError locally, with the server’s own wording

12

fetch(ids=...), fetch_by_metadata(limit=...), list_paginated(prefix=..., limit=...)

fetch checked only that ids was non-empty; fetch_by_metadata checked only that limit was positive; list_paginated validated neither argument

all three validate the same ID/prefix shape and limit range every other vector operation already used

Rows 1, 3, and 4 change the bytes the SDK puts on the wire. Rows 2, 6, and 7 change documentation that was wrong, not behavior. Row 5 changes what a working index declaration looks like, covered above under Sparse writes require a declared field. Row 8 needed no fix on this surface. Rows 9-12 add client-side checks for requests the server was already going to refuse.

1. query(top_k=...) is bounded at both ends on every lane

query has always had an upper bound on top_k. Until this release only GrpcIndex enforced it; Index and AsyncIndex forwarded anything larger and it came back as a server error. All three lanes now share one range check:

idx.query(vector=[0.1, 0.2], top_k=20000, namespace="movies-en")
# PineconeValueError: top_k must be between 1 and 10000, got 20000

The bound is 1-10000 on Index, AsyncIndex, and GrpcIndex alike, and it’s checked before any request is made. A call the server was going to reject now fails locally instead, with nothing to un-send. The ceiling is a deployment setting, not a constant of the API: a deployment configured lower than the client’s ceiling still rejects values this check lets through, and that arrives as an ApiError. Catch PineconeError to handle both, or clamp before you call.

2. describe_index_stats(filter=...) never returns a filtered count

The docstrings and how-to guide described a working metadata filter. Both were wrong: a non-empty filter is rejected for every index type, so a filtered stats call fails rather than returning a subset count. There’s no operation anywhere on this surface that counts only the records matching a metadata filter, so if you built a count on this argument, it was never working, and there’s nothing to migrate it to.

# This has never worked. The call fails; it does not return a subset count.
stats = idx.describe_index_stats(filter={"genre": {"$eq": "action"}})

Drop the argument. The statistics you get back describe the whole index:

stats = idx.describe_index_stats()
print(stats.total_vector_count, stats.dimension)

No behavior changed here, all three lanes still forward the filter unvalidated and the server still rejects it. What changed is that the documentation now says so. To count a subset, query for it or maintain the count yourself.

3. SchemaBuilder metadata fields always emit filterable

add_boolean_field(), add_float_field(), and add_string_list_field() left filterable out of the emitted field whenever it was False, the default, so the shortest documented call produced a create the backend rejected outright. The key is now always present:

schema = (
    SchemaBuilder()
    .add_boolean_field("is_published")
    .add_float_field("year")
    .add_string_list_field("tags")
    .build()
)
schema["fields"]["is_published"]
# before: {'type': 'boolean'}
# now:    {'type': 'boolean', 'filterable': False}

If you assert on build()’s output, add filterable to the expected boolean, float, and string-list fields. filterable=True was unaffected. add_string_field() is deliberately unchanged; its wire shape is different enough that the same edit would break it, and its intended shape is still an open question.

Warning

A string field can be declared for metadata filtering or for full-text search, and the full-text-search spelling doesn’t currently reach the backend as full-text search: the field is created as filter-only metadata, with no error and no warning, so the omission is invisible until a search returns nothing. Don’t take a full-text-search string field from any example on this page as working yet. Boolean, float, and string-list fields are unaffected.

Passing metric= or dimension= to add_sparse_vector_field() now raises instead of silently forwarding a key that does nothing. See Sparse writes require a declared field for the full write-up.

6. start_import(error_mode=...) defaults to "abort"

The docstrings and the bulk-import how-to said the default was "continue", meaning a record the import can’t read is skipped and the rest still import. The default is actually "abort": the import ends at the first record it can’t read. The server has always behaved this way; only the documentation was wrong, so no running import changes, but if you omitted error_mode on the strength of that sentence, you’ve been getting the opposite of what you read.

# Ends the whole import at the first unreadable record (the default).
idx.start_import(uri="s3://my-bucket/vectors/")

# Opt in to skipping unreadable records and importing the rest.
idx.start_import(uri="s3://my-bucket/vectors/", error_mode="continue")

7. create_namespace(schema=...) omitted means inherit, not index everything

Omitting schema doesn’t create a namespace with every field indexed; the namespace inherits the index’s own metadata-index configuration.

# Inherits the index's metadata-index configuration, whatever that is.
ns = idx.create_namespace(name="movies-en")

# Overrides it for this namespace alone: exactly these fields are indexed.
ns = idx.create_namespace(
    name="movies-en",
    schema={"fields": {"genre": {"filterable": True}}},
)

Supplying schema overrides the inherited configuration for that namespace only. Each field listed must set filterable: True; to leave a field unindexed, omit it entirely. Behavior didn’t change here, this was already how the server worked. If you relied on the omitted form to index everything on a restricted index, supply schema explicitly now.

9. query rejects id alongside sparse_vector, not just vector

query accepts a stored vector’s id, literal vector/sparse_vector data, or both vector forms together for a hybrid query, but never id together with either. Before this release only the id+vector combination was checked client-side; id+sparse_vector was forwarded and refused by the server instead. Both now raise before any request is sent:

idx.query(id="article-101", sparse_vector={"indices": [0, 1], "values": [0.5, 0.5]}, top_k=10)
# PineconeValueError: id is mutually exclusive with sparse_vector — a query uses
# a stored vector's id OR literal vector data, not both. Pass id alone to query
# by stored vector, or sparse_vector alone to query by value. Cannot provide
# both 'ID' and 'sparse_vector' at the same time

A hybrid query, vector and sparse_vector together with no id, is unaffected.

10. A by-filter update cannot carry values or sparse_values

A by-filter update spans every record the filter matches, so it can only set metadata. The SDK now catches this before the request leaves the process:

idx.update(filter={"genre": {"$eq": "drama"}}, values=[0.1, 0.2, 0.3])
# PineconeValueError: filter is mutually exclusive with values — a by-filter
# update is metadata-only, because it spans every record the filter matches.
# Pass set_metadata to update metadata by filter, or id to update one record's
# vector values. Update by metadata request does not support updating vector
# values.

Update by id to change one record’s vector values; update by filter to change metadata on every matching record, via set_metadata. id and filter together were already rejected before this release, and still are, that check didn’t change.

11. An empty filter={} now raises locally

delete, update, and fetch_by_metadata have always rejected a metadata filter with no conditions. Before this release the empty dict was forwarded and refused by the server; now it raises before any request is sent, with the server’s own wording:

idx.delete(filter={})
# PineconeValueError: filter must contain at least one condition, got {}.
# Delete with empty metadata filter is not allowed

delete is the one operation here with a true match-everything mode, spell it with delete_all=True, not an empty filter.

12. fetch, fetch_by_metadata, and list_paginated validate up front

fetch’s ids, list_paginated’s prefix, and every vector id share one rule: 1-512 ASCII characters, no NUL. Before this release, fetch checked only that ids was non-empty and list_paginated validated neither argument at all. Both now raise locally:

idx.fetch(ids=["a" * 600])
# PineconeValueError: ids[0] exceeds the maximum length of 512 characters, got 600: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'...'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' (600 characters)

Page sizes follow the same pattern. list_paginated’s limit is now bounded 1-100, and fetch_by_metadata’s limit is now also bounded above, at 10000:

idx.list_paginated(limit=500)
# PineconeValueError: limit must be between 1 and 100, got 500

idx.fetch_by_metadata(filter={"genre": {"$eq": "comedy"}}, limit=20000)
# PineconeValueError: limit must be between 1 and 10000, got 20000

As with row 1’s top_k bound, these are deployment settings the server enforces, not constants of the API.

What you don’t need to change

Nothing on this page changes a method signature. Any working query call keeps working, row 1 only rejects values the server was going to reject anyway. Vector operations against a legacy index are unaffected, rows 3, 4, and 5 are about creating an index, so none of them reaches one you already have. describe_index_stats without a filter is unchanged. And nothing about error_mode or create_namespace(schema=...) that you already had right needs to change; rows 6 and 7 correct documentation, not behavior.

Backups and backup schedules

The backup response models now follow the 2026-07 API shapes, which is a breaking change to BackupModel. The operations themselves change additively.

What changed on BackupModel

BackupModel no longer has .dimension or .metric. .schema changed type too: it was a plain dict shaped like the old metadata schema, and is now a typed IndexSchema, the same class returned by index.schema.

Removed

Replacement

backup.dimension

backup.schema.fields["<field>"].dimension, or backup.dense_dimension

backup.metric

backup.schema.fields["<field>"].metric

backup.schema["fields"]["<name>"]["filterable"]

backup.schema.fields["<name>"].filterable

# 9.x
dim = backup.dimension
metric = backup.metric

# 10.x
dim = backup.dense_dimension
metric = backup.schema.fields["embedding"].metric

dense_dimension is a convenience for the common one-vector-field case. It returns None when the schema is absent, declares no dense_vector field, or declares more than one, read the field you want out of backup.schema.fields in that case.

New field on BackupModel: source_index_deleted_at, the deletion timestamp of the source index, or None when that index is still active. Only list_index_backups(include_deleted=True) populates it.

CreateIndexFromBackupRequest is exported from pinecone, pinecone.models, and pinecone.models.backups. PreviewBackupModel and PreviewCreateBackupRequest are removed along with the preview package; preview backup operations now return the same top-level BackupModel as everything else.

Operations

No backup method lost an argument. pc.backups.list gained a keyword-only include_deleted: bool | None = None. pc.create_index_from_backup gained a keyword-only read_capacity: dict | None = None. And pc.indexes.create_backup / list_backups / describe_backup graduated out of the preview namespace onto pc.indexes directly.

include_deleted and what a 404 means

pc.backups.list(index_name=...) (and pc.indexes.list_backups(...)) resolve the name against active indexes by default. If every index that used the name has been deleted, the API returns 404 rather than an empty list, so a 404 alone is not proof the name was never used:

State of index_name in the project

omitted / False

True

An active index has the name

its backups

backups of every index that has held the name, active and deleted

Only deleted indexes have held the name

404, not an empty list

those backups, each with source_index_deleted_at set

The name has never existed

404

404

The name exists but has no backups

empty list

empty list

A 404 is only conclusive when you sent include_deleted=True. A 404 without it proves nothing about whether the name was ever used, retry with include_deleted=True before concluding anything.

# 10.x: recover the backups of an index you already deleted
page = pc.backups.list(index_name="product-search", include_deleted=True)
orphaned = [b for b in page if b.source_index_deleted_at]

include_deleted on the project-wide pc.backups.list() raises PineconeValueError, because that operation doesn’t accept it, the project-wide listing already returns backups whose source index was deleted.

read_capacity on restore

Warning

read_capacity= on a restore is accepted and silently ignored. The field serializes and the request succeeds, but the backend doesn’t read a read-capacity value from a restore request today, so the index always lands on on-demand capacity (or, for a BYOC restore, a fixed default tier, which also means a BYOC restore loses the source index’s provisioned tier). There’s no error and no warning.

Until that’s fixed, restoring onto dedicated read nodes is two calls: restore, then configure.

job = pc.create_index_from_backup(
    name="product-search-restored",
    backup_id="bk-abc123",
    timeout=-1,
)
index = pc.indexes.configure(
    "product-search-restored",
    read_capacity={
        "mode": "Dedicated",
        "dedicated": {
            "node_type": "t1",
            "scaling": "Manual",
            "manual": {"shards": 2, "replicas": 2},
        },
    },
)

create_index_from_backup remains the only supported restore path; pc.create_index(source_backup_id=...) raises a PineconeTypeError pointing at it.

Note

Don’t poll a restore job for an in-progress state. restore_job.status is one of Pending, Failed, Completed, Cancelled and nothing else, and percent_complete is 100 on Completed and None otherwise, never an intermediate percentage. A loop that waits for a "Running" status, or for percent_complete to climb, never terminates. Wait on status == "Completed" and treat Failed/Cancelled as terminal.

AsyncPinecone.backups mirrors pc.backups one-for-one, only await differs:

page = pc.backups.list(index_name="product-search", include_deleted=True)

Backup schedules

pc.backup_schedules is additive, there’s no 9.x equivalent. Six operations: create, list, describe, update, delete, history, plus the lazy iter_schedules / iter_history walkers. Three behaviors are worth knowing before you rely on this.

Plan gating is asymmetric between create and update. Backups are a plan entitlement, gated on exactly the Free and Builder plans. create always checks the entitlement, before the index is even looked up, so a project without the entitlement gets a 403 for a nonexistent index name too; the 403 is about the plan, never about the index. Toggling update from disabled to enabled checks the entitlement as well, but disabling a schedule, or changing its frequency or retention_days, does not. That’s intentional, so a downgraded organization can still turn off or retune a schedule it can no longer create.

Warning

On-demand backups are gated by the same entitlement. pc.backups.create() checks it before its own index lookup, so a 403 from a schedule operation is not a reason to fall back to pc.backups.create(), that call answers 403 for the same projects.

An index may hold at most one enabled schedule. Disabled schedules don’t count, so you can keep several and enable one at a time. The conflict surfaces as a 409 whether it comes from create while one is already enabled, or from update re-enabling while another is enabled.

Re-enabling a disabled schedule runs a backup immediately and shifts the cadence. update(enabled=True) on a disabled schedule recomputes next_scheduled_run from the moment of the update, not from the schedule’s original anchor, and enqueues a backup for that new time. A daily schedule anchored at 06:00 UTC, disabled and re-enabled at 15:20, runs daily at 15:20 from then on; there’s no way to re-enable without this side effect. Sending enabled=True on an already-enabled schedule is a safe no-op.

schedule = pc.backup_schedules.create(
    index_name="product-search",
    name="daily-compliance-backup",
    frequency="daily",
    retention_days=90,
)
paused = pc.backup_schedules.update(schedule_id=schedule.schedule_id, enabled=False)
resumed = pc.backup_schedules.update(schedule_id=schedule.schedule_id, enabled=True)

list and history return one page each, and limit is dropped whenever pagination_token is given (the token already encodes the page size). Send limit on the first call only, or use iter_schedules / iter_history, which follow the tokens for you. The same rule holds for pc.backups.list and pc.restore_jobs.list.

A few smaller behavior notes: backup status is documented as Initializing, Ready, or Failed, and the backend currently returns InitializationFailed in place of Failed; the model keeps status a plain string, so both decode. schema is None when the server omits it. tags is None when the source index had no tags, and BackupList.pagination is None on the final page.

Assistant

AssistantFileModel: removed fields

AssistantFileModel no longer has .percent_done or .error_message. File processing progress and failure detail moved to the operations API:

Removed

Replacement

file.percent_done

describe_operation(...) and read OperationModel.status

file.error_message

describe_operation(...) and read OperationModel.error

Accessing either attribute raises an AttributeError naming describe_operation as the replacement:

file = pc.assistant.describe_file(assistant_name="my-assistant", file_id="f-1")
file.error_message
# AttributeError: AssistantFileModel.error_message was removed in the 2026-07
# Pinecone API: processing failure detail is reported by the operations API
# instead — call describe_operation() and read OperationModel.error. ...

Dict-style access follows suit: file["percent_done"] raises KeyError, and neither name appears in file.keys() or file.to_dict(). Responses that still carry the old keys, from a 2025-10 server or a recorded fixture, continue to decode; the extra keys are ignored.

upload_file() and delete_file() used to quote error_message in the error they raised on a failed poll. With the field gone, those messages now name the file state and point to describe_operation() for the reason. Code matching on the old message text needs updating.

File IDs are no longer UUIDs

2026-07 documents id as a plain string, because a file ID may be one the caller supplied. Code that parses a file ID as a UUID breaks:

uuid.UUID(file.id)  # no longer safe

size (bytes, int64) is part of the documented shape now, populated on upload, describe, and list responses.

AssistantModel.region

AssistantModel gains region, "us" or "eu", the region the assistant is deployed in. create takes region too, defaulting to "us". It’s fixed at creation time, there’s no move-an-assistant operation, so an eu-resident assistant has to be created that way:

assistant = pc.assistants.create(name="eu-assistant", region="eu")
assistant.region  # 'eu'

Not every deployment can serve eu; where it can’t, the request is refused with a message saying so.

Model names and finish reasons

The 2026-07 data API changes which model names it documents on chat() and which finish_reason values it returns. The SDK doesn’t validate model client-side, the backend is authoritative, so these surface as backend rejections and as different strings, not SDK type errors.

claude-3-5-sonnet and claude-3-7-sonnet are no longer documented. Migrate to claude-sonnet-4-5:

response = pc.assistants.chat(
    assistant_name="my-assistant",
    messages=[{"content": "What is Pinecone?"}],
    model="claude-sonnet-4-5",  # was "claude-3-5-sonnet"
)

The two old names are still accepted as deprecated aliases and silently remapped to claude-sonnet-4-5, so existing code keeps working, but the responses come from a different model than the name suggests. gpt-4o remains the SDK default. An unrecognized model name is rejected with a 400 whose message lists the values the backend accepts.

Wherever the API reports why generation stopped, the value function_call has been replaced by tool_calls. The SDK types these as plain strings, so nothing in the SDK breaks, but code matching on the string does:

if response.finish_reason == "function_call":  # never true on 2026-07
    ...
if response.finish_reason == "tool_calls":  # replacement
    ...

New, additive fields: ChatResponse gains context_snippet_count and content_filter_results; StreamMessageStart gains context_snippet_count; StreamMessageEnd gains finish_reason; context-snippet references gain a type field (text, json, markdown, pdf, or doc_x) naming the kind of document a snippet came from.

File uploads and deletes are operations

On 2026-07 the assistant file endpoints stopped answering with the file and started answering with an operation. upload_file and delete_file keep their signatures and return types, the SDK performs the new handshake internally, but the wire contract and what delete_file guarantees when it returns both changed. This applies to AsyncPinecone().assistants identically.

upload_file reads the operation, polls it until it reports Completed, and then calls describe_file, so this keeps working unchanged:

file = pc.assistants.upload_file(
    assistant_name="my-assistant",
    file_path="/data/report.pdf",
)
file.status  # "Available"

Progress now comes from the operation rather than the file’s status. timeout=-1 still skips polling and returns one describe_file immediately, but on 2026-07 that means the file may still exist mid-upload when the call returns, since it means “request accepted” rather than “file processed.”

Metadata moved from a query parameter into the multipart body. The backend rejects the old form outright rather than ignoring it. Through the SDK nothing changes, keep passing a dict:

pc.assistants.upload_file(
    assistant_name="my-assistant",
    file_path="/data/report.pdf",
    metadata={"tags": ["report", "Q4"], "published": "2025-10-01"},
    multimodal=True,
)

Upload failures now quote the server. With error_message gone from AssistantFileModel, the SDK raises PineconeError naming the file, the operation, and the server’s message verbatim:

try:
    pc.assistants.upload_file(assistant_name="my-assistant", file_path="/data/logo.gif")
except PineconeError as exc:
    print(exc)
    # Upload of file 'ae79e447-…' failed (operation_id='op-1234-abcd-5678'):
    # Uploaded file can only currently be either a pdf or txt file

Previously a failure surfaced as File processing failed for '<id>' with no reason attached. Code matching on that old text needs updating.

delete_file is genuinely asynchronous now. It answers either a 202 with an operation (deletion pending, polled every 5s), or a 204 with no body (the file was removed at once). Both are success; the old implementation polled describe_file until it 404’d, so a failed deletion now raises with the server’s reason instead of just timing out:

pc.assistants.delete_file(assistant_name="my-assistant", file_id="file-abc123")
# returns once the deletion operation has completed

timeout=-1 returns as soon as the request is accepted, which on 2026-07 means the file may still exist when the call returns. Use the default (poll indefinitely) or a positive deadline if you need the deletion done.

AssistantFileModel.id also dropped its UUID format constraint, since the upsert endpoint lets you choose the identifier. Don’t parse a file id as a UUID.

The operations API

list_operations and describe_operation are new methods on both clients. They report on the long-running work the file endpoints now start: upload_file, a metadata update, and delete_file each create an operation server-side. You don’t need them for the default flow, upload_file and delete_file poll for you and return only once the work is done. They’re for the cases where you deliberately didn’t wait.

If you passed timeout=-1 to upload_file, follow the work by finding the operation for the file it belongs to:

operations = pc.assistants.list_operations(
    assistant_name="my-assistant",
    operation_type="upload_file",
    status="Processing",
)
mine = [op for op in operations if op.file_id == file.id]

describe_operation is the progress check, and where the failure reason lives now:

operation = pc.assistants.describe_operation(
    assistant_name="my-assistant",
    operation_id="op-1234-abcd-5678",
)
if operation.status == "Failed":
    print(operation.error)

Read error only when status is "Failed"; a retried operation keeps the previous attempt’s text, so a non-None error isn’t by itself evidence of failure. The async form is the same call with await:

operation = await pc.assistants.describe_operation(
    assistant_name="my-assistant",
    operation_id="op-1234-abcd-5678",
)

list_operations is a lazy paginator over both in-flight and finished operations, so it doubles as an audit log, a failed upload is discoverable after the fact now:

failures = pc.assistants.list_operations(
    assistant_name="my-assistant",
    status="Failed",
).to_list()

Rate limits

The assistant error-code enum gained TOO_MANY_REQUESTS. The SDK maps a 429 to RateLimitError, which carries retry_after when the server sends a Retry-After header, and 429 is in the SDK’s default retry set, so the common case is handled before you see it. Catch it when you want to back off on your own schedule:

from pinecone.errors.exceptions import RateLimitError

try:
    pc.assistants.list()
except RateLimitError as exc:
    print(exc.retry_after, exc.error_code)

Checklist

Nothing in this section is caught by a type checker, so grep for it:

  • claude-3-5-sonnet, claude-3-7-sonnet in model= arguments

  • "function_call" compared against a finish_reason

  • .error_message and .percent_done on a file object

  • uuid.UUID(...) applied to a file id

  • timeout=-1 on upload_file or delete_file, and whether the code after it assumes the work finished

Inference

embed, rerank, list_models, and get_model keep their signatures, arguments, and return types. The API version header moved to 2026-07, and two long-standing serialization bugs were fixed. Both changed what the SDK puts on the wire without changing what you write.

rerank()’s documented exceptions also gained NotFoundError, which is what an unknown model name has always raised. Code that only caught ForbiddenError around rerank was catching the wrong one:

from pinecone.errors.exceptions import ForbiddenError, NotFoundError

try:
    pc.inference.rerank(model="bge-reranker-v2-m3-typo", query="q", documents=["d"])
except NotFoundError:
    pass  # no such model, usually a typo
except ForbiddenError:
    pass  # the model exists; this project may not use it

Enum members now serialize correctly

EmbedModel, RerankModel, and VectorType are (str, Enum) mixins. Passing a member used to send the wrong value on the wire, because the old code paths serialized with str(member), which returns the member’s repr-style name (EmbedModel.Multilingual_E5_Large) rather than its value (multilingual-e5-large). Passing the plain string always worked, and that’s unaffected here.

If every call you make passes model= or vector_type= as a string literal, nothing changes for you. If any call passes an enum member, that call was failing before and works now: it failed loudly, not silently, since no such model or vector type exists, so the server rejected the request rather than serving it wrong. Passing .value was the documented workaround while these bugs were open, and it’s still correct; .value is exactly what the SDK now extracts for you, so there’s nothing to clean up if you already adopted it.

The SDK doesn’t repair a mangled name. Passing the literal string "EmbedModel.Multilingual_E5_Large" sends it verbatim and still raises NotFoundError, because that’s a model id the server doesn’t have.

Request bodies: embed() and rerank()

from pinecone import EmbedModel

str(EmbedModel.Multilingual_E5_Large)   # 'EmbedModel.Multilingual_E5_Large'
EmbedModel.Multilingual_E5_Large.value  # 'multilingual-e5-large'

model= argument

"model" sent before

"model" sent now

"multilingual-e5-large"

multilingual-e5-large

multilingual-e5-large

EmbedModel.Multilingual_E5_Large

EmbedModel.Multilingual_E5_Large

multilingual-e5-large

RerankModel.Bge_Reranker_V2_M3

RerankModel.Bge_Reranker_V2_M3

bge-reranker-v2-m3

Model 'EmbedModel.Multilingual_E5_Large' not found
pc.inference.embed(model=EmbedModel.Multilingual_E5_Large.value, inputs=["hello"])

A model id the installed SDK has no member for, one released after it, is still accepted as a plain string and sent through unchanged:

pc.inference.embed(model="some-newer-embedding-model", inputs=["hello"])
pc.inference.embed(model="EmbedModel.Multilingual_E5_Large", inputs=["hello"])
# NotFoundError: Model 'EmbedModel.Multilingual_E5_Large' not found

Query strings: list_models()

inference.list_models() accepts vector_type as a plain string or as a VectorType member; the fix applies there and to the pc.inference.model.list() facade, on both the sync and the async client.

from pinecone import VectorType

str(VectorType.DENSE)   # 'VectorType.DENSE'
VectorType.DENSE.value  # 'dense'

vector_type= argument

query sent before

query sent now

"dense"

vector_type=dense

vector_type=dense

VectorType.DENSE

vector_type=VectorType.DENSE

vector_type=dense

VectorType.SPARSE

vector_type=VectorType.SPARSE

vector_type=sparse

Invalid vector_type, expected one of [dense, sparse]
pc.inference.list_models(type="embed", vector_type=VectorType.DENSE.value)

The fix lives at the encoder that every request passes through, so no query parameter on any surface, including one added later, can carry a mangled member. Passing the literal string "VectorType.DENSE" is rejected before a request is made, it isn’t one of the values the parameter accepts:

pc.inference.list_models(vector_type="VectorType.DENSE")
# PineconeValueError: vector_type must be one of 'dense', 'sparse', got 'VectorType.DENSE'

Admin and OAuth

Both surfaces are additive at 2026-07: no field on any pre-existing request or response changed, was renamed, or was removed. Existing Admin code compiles and behaves as it did before. What’s new is four namespaces, users, invites, service accounts, and role bindings, which together make organization membership and RBAC manageable from the SDK for the first time.

Admin is synchronous only. There’s no async form, admin calls are infrequent control-plane operations.

ADMIN_API_VERSION is now 2026-07, sent on every admin request and on the OAuth token exchange. Nothing else about authentication changed, the OAuth surface is a pure version bump.

New: users, invites, service accounts, role bindings

Resource

SDK methods

Users

admin.users.list(), .describe(), .delete()

Invites

admin.invites.list(), .create(), .describe(), .delete(), .resend()

Service accounts

admin.service_accounts.list(), .create(), .describe(), .update(), .delete(), .rotate_secret()

Role bindings

admin.role_bindings.list(), .create(), .describe(), .delete()

New models, all additive: UserModel, UserList; InviteModel, InviteList, InviteStatus; ServiceAccountModel, ServiceAccountList, ServiceAccountWithSecret; RoleBindingModel, RoleBindingList, RoleBindingInput, PrincipalType, ResourceType, RoleName. Everywhere a role binding is accepted, a plain dict works too. The four listing operations above return a lazy Paginator, unlike the older projects/organizations/api_keys listings, which return eager *List objects.

The three new resources compose, this is the workflow they exist for:

from pinecone import Admin
from pinecone.models.admin import PrincipalType, ResourceType, RoleName

admin = Admin(client_id="...", client_secret="...")
project = admin.projects.create(name="search-prod")

# 1. A service account for CI, with no permissions yet.
created = admin.service_accounts.create(name="ci-search-prod")
store_secret(created.client_secret)  # returned exactly once

# 2. Grant it ownership of that one project. Bindings are what confer access;
#    a service account with none can get a token but do nothing with it.
admin.role_bindings.create(
    principal_type=PrincipalType.SERVICE_ACCOUNT,
    principal_id=created.service_account.id,
    resource_type=ResourceType.PROJECT,
    resource_id=project.id,
    role=RoleName.PROJECT_OWNER,
)

# 3. Invite a human, with their initial roles in the same call. At least one
#    organization-scoped membership role is required.
invite = admin.invites.create(
    email="teammate@example.com",
    role_bindings=[
        {"resource_type": "organization", "role": "OrgMember"},
        {"resource_type": "project", "role": "ProjectViewer",
         "resource_id": project.id},
    ],
)

# 4. Read any principal's access back through role_bindings, not through the
#    principal's own model, no other namespace carries bindings.
for binding in admin.role_bindings.list(
    principal_type="service_account",
    principal_id=created.service_account.id,
):
    print(binding.role, binding.resource_type, binding.resource_id)

Two things worth knowing about that shape. Role bindings aren’t part of any principal’s own representation: UserModel, ServiceAccountModel, and InviteModel don’t carry them, so admin.role_bindings.list() filtered by principal is the only way to read them. And bindings are immutable, there’s no update. Changing a role is create for the new one then delete for the old one, in that order, deleting first can strip a principal’s last organization-membership binding, which the server refuses with a 409.

Invites: list never shows accepted invites

InviteStatus has three values: pending, expired, and processed. admin.invites.list() returns only the first two. An accepted invite disappears from the listing without being deleted, and admin.invites.describe(invite_id=...) still returns it with status == InviteStatus.PROCESSED. Don’t treat absence from list() as proof an invite never existed, or as license to send a duplicate, a second create for an address that already belongs to a member is a 409.

ids = {i.id for i in admin.invites.list()}
"9c8e3528-..." in ids                      # False, could mean accepted
admin.invites.describe(invite_id="9c8e3528-...").status  # 'processed'

Once an invite is accepted, manage the invitee through admin.users and admin.role_bindings, not admin.invites. delete and resend on a processed invite are both a 409.

Pointing Admin at a non-production host

Previously Admin ignored PINECONE_CONTROLLER_HOST and always talked to production. It now applies the same host resolution as Pinecone: the host keyword first, then PINECONE_CONTROLLER_HOST, then the default. This is a behavior change for anyone who had that variable set in an environment where Admin also runs, even though no API surface changed, admin traffic that used to go to production will now follow the variable.

admin = Admin(client_id="...", client_secret="...", host="http://localhost:5080")

oauth_url is a second new keyword, pointing the token exchange somewhere other than production. It takes the full URL including the path, and has no environment-variable fallback:

admin = Admin(
    client_id="...", client_secret="...",
    host="http://localhost:5080",
    oauth_url="http://localhost:5080/oauth/token",
)

Both are keyword-only, and intended for local simulators and private deployments. Leave both unset against production.

Token refresh

Admin now keeps its own bearer token current: it re-mints ahead of the stated expiry, and retries a request once against a fresh token if one still comes back 401. A long-lived Admin no longer starts returning bare 401s after its first token lapses, and threads sharing one Admin cost a single token exchange between them. Passing your own Authorization entry in additional_headers opts out of refresh entirely, the token is then yours to manage.

Project deletion now clears assistants too

admin.projects.delete() requires an empty project, and indexes, collections, assistants, and backups each block it with a 412 naming what’s left. API keys aren’t a blocker, they’re deleted with the project. admin.projects.delete_with_cleanup() clears all four, assistants included. Earlier releases left assistants behind, so a project holding one still failed the final delete after a nominally successful cleanup; that gap is closed.

TLS/SSL configuration

Pinecone, AsyncPinecone, Index, and AsyncIndex accept ssl_ca_certs and ssl_verify, and Admin accepts ssl_verify. Until this release none of them did anything: both settings were resolved from config and handed to the underlying HTTP client, but the SDK’s own transport (added for connection retries and TCP keep-alive tuning) was built with the library’s defaults regardless of what you passed. Every connection used the default trust store no matter what you configured. The signatures didn’t change, so there’s no code to edit, but the requests your client makes may now be verified differently than before.

keyword arguments

TLS before

TLS now

{}

default trust store, hostname checked

default trust store, hostname checked

{"ssl_verify": False}

default trust store, hostname checked

verification off, hostname not checked

{"ssl_ca_certs": "bundle.pem"}

default trust store, hostname checked

only that bundle trusted, hostname checked

{"ssl_ca_certs": "ca-dir"}

default trust store, hostname checked

only that directory trusted, hostname checked

{"ssl_ca_certs": "missing.pem"}

default trust store, hostname checked

FileNotFoundError when the client is built

ssl_ca_certs continues to win over ssl_verify when both are given, as it always has: supplying a bundle means you want that bundle trusted.

If you pass neither argument, nothing changes, this is the overwhelmingly common case. If you pass ssl_ca_certs because you sit behind a TLS-inspecting proxy, your bundle is now the one that’s actually trusted, which is what you asked for originally; if your connections were succeeding before on the default trust store, they’ll now succeed or fail on your bundle instead. If you pass ssl_verify=False, verification and hostname checking are now genuinely off. Traffic is still encrypted, but the SDK no longer confirms it’s talking to the host it dialled, so only use it against an endpoint you control.

If you pass an ssl_ca_certs path that doesn’t exist, building the client now raises FileNotFoundError instead of silently ignoring the setting. A path that exists but holds no readable certificate raises ssl.SSLError. Pinecone and Index raise at construction; AsyncPinecone and AsyncIndex build their connection pool on first use, so they raise at the first request instead.

Admin(ssl_verify=False) now applies to the OAuth token exchange as well as the Admin API requests that follow it, the token exchange uses its own client, so before this release the setting was ignored on both.

GrpcIndex has no ssl_ca_certs or ssl_verify of its own, but its secure=False is forwarded to the REST client that backs upsert_records and search, where it means the same as ssl_verify=False above. Those two operations are unverified under secure=False where before they were verified. The gRPC channel itself is unaffected, secure has always chosen its own scheme.