Backups and restore

Backups are point-in-time snapshots of an index. Use them to protect against data loss, create copies of an index, or restore a previous state.

Create a backup

Pass the name of the index you want to back up:

from pinecone import Pinecone

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

backup = pc.backups.create(index_name="product-search")
print(backup.backup_id)   # e.g. "bk-abc123"
print(backup.status)      # e.g. "Initializing"

Add a name and description for easier identification:

backup = pc.backups.create(
    index_name="product-search",
    name="pre-reindex-snapshot",
    description="Backup before schema migration on 2025-03-01",
)

The backup transitions through InitializingReady when complete.

The same operation is available index-first on the indexes namespace, if that reads better in an index-centric flow:

backup = pc.indexes.create_backup("product-search", name="pre-reindex-snapshot")

List backups

pc.backups.list returns a BackupList, one page, not every backup in the project. It carries a pagination token but does not follow it, so iterating the returned object directly sees at most one page. To list all backups, drive the token:

page = pc.backups.list(limit=100)
backups = list(page)
while page.pagination and page.pagination.next:
    page = pc.backups.list(pagination_token=page.pagination.next)
    backups.extend(page)

for backup in backups:
    print(backup.backup_id, backup.name, backup.status)

When you omit limit, the server applies its own default.

Pagination here is offset-based, not cursor-based. The token names a position in the result set, not a stable cursor over a snapshot. Three consequences follow:

  • limit is omitted whenever a token is given. The token already carries the page size it was minted with, and sending a different limit alongside it would skip or repeat rows.

  • Backups created or deleted between requests shift every later offset, so a walk can miss rows and return others twice. De-duplicate by backup_id rather than trusting the sequence.

  • A malformed or truncated token is rejected with 400 rather than restarting the listing.

pagination is None only when a page comes back shorter than limit, so a final page that happens to be exactly limit long still carries a token. Following it costs one extra request that returns nothing. Terminate on pagination is None, as the loop above does.

Filtering by index is the same single-page contract, so the same loop applies:

page = pc.backups.list(index_name="product-search", limit=100)
for backup in page:
    print(backup.backup_id, backup.created_at)

pc.indexes.list_backups is the index-scoped equivalent, returning a Paginator that walks the pages for you. Prefer it whenever you want every backup for one index and would rather not drive tokens by hand:

for backup in pc.indexes.list_backups("product-search"):
    print(backup.backup_id, backup.status)

Backups of a deleted index

A backup outlives its source index, so an index-scoped listing has to say what it means by the index name. By default it means the active index. If every index that used the name has been deleted, the API answers 404, not an empty list.

Pass include_deleted=True to widen the listing to every index that has ever used the name:

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

for backup in page:
    if backup.source_index_deleted_at:
        print(backup.backup_id, "orphaned at", backup.source_index_deleted_at)

This is one page like every other pc.backups.list call. Use the token loop above if the index has more backups than a page holds.

So a 404 from pc.backups.list(index_name=...) is not proof the name was never used. Retry with include_deleted=True before concluding that. A 404 with include_deleted=True does mean the name has never existed in this project.

include_deleted applies only to index-scoped listings. The project-wide pc.backups.list() already returns backups whose source index is gone, and passing include_deleted there raises PineconeValueError rather than being silently ignored. Omitting the argument leaves the parameter off the request entirely, so the server’s default applies.

Describe a backup

backup = pc.backups.describe(backup_id="bk-abc123")
print(backup.source_index_name)
print(backup.status)
print(backup.dense_dimension)
print(backup.schema.fields["embedding"].metric)
print(backup.record_count)
print(backup.size_bytes)
print(backup.source_index_deleted_at)

schema is an IndexSchema, the same typed model returned by pc.indexes.describe(...).schema, and is None when the server returns no schema for the backup.

pc.indexes.describe_backup("bk-abc123") is the same call under the indexes namespace.

Restore a backup to a new index

Use create_index_from_backup on the top-level client to restore a backup into a new index:

from pinecone import Pinecone

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

index = pc.create_index_from_backup(
    name="product-search-restored",
    backup_id="bk-abc123",
)
print(index.name)
print(index.status.state)

create_index_from_backup polls until the new index is ready. Pass timeout=-1 to return immediately:

result = pc.create_index_from_backup(
    name="product-search-restored",
    backup_id="bk-abc123",
    timeout=-1,
)
print(result.restore_job_id)

Enable deletion protection or add tags to the restored index:

index = pc.create_index_from_backup(
    name="product-search-restored",
    backup_id="bk-abc123",
    deletion_protection="enabled",
    tags={"env": "production", "team": "search"},
)

What a restore does and does not carry over

tags behaves as documented. Pass them and they win; omit them and the restored index inherits the backup’s tags. deletion_protection is taken from the request only. It is never inherited from the source index.

Warning

read_capacity is accepted on the request but has no effect today. The 2026-07 OAS declares read_capacity on the restore body, and the SDK sends what you pass, but the backend’s restore handler does not read the field. It hardcodes on-demand capacity for a serverless restore and a fixed B1 tier with one shard and one replica for a BYOC restore, whatever the source index or the request asked for.

The SDK keeps the spec-shaped parameter rather than rejecting it client-side, so the call still succeeds. It just silently lands on defaults. Until the backend honours the field, size a restored index with a configure call after the restore rather than expecting the restore to land on dedicated capacity:

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

Two more server-side gates are worth knowing before you call:

  • The backup must be finished. A backup that is not complete is refused with 412. The message reads Backup {backup_id} is not completed, naming the store’s raw Completed status rather than the Ready status the SDK surfaces on BackupModel, so a 412 here means “status is not yet Ready”, not that you passed the wrong value anywhere.

  • Pod backups cannot be restored. A backup whose source index was a pod index is refused with 400 “Backups from pod indexes are not supported”.

create_index_from_backup is the only supported way to restore a backup. pc.create_index(source_backup_id=...) raises a PineconeTypeError pointing here.

Monitor restore jobs

Each call to create_index_from_backup starts a restore job.

pc.restore_jobs.list returns a RestoreJobList holding one page. Like pc.backups.list, it carries a pagination token without following it, so for job in pc.restore_jobs.list(): sees at most one page of jobs. Drive the token to walk the rest:

page = pc.restore_jobs.list(limit=100)
jobs = list(page)
while page.pagination and page.pagination.next:
    page = pc.restore_jobs.list(pagination_token=page.pagination.next)
    jobs.extend(page)

for job in jobs:
    print(job.restore_job_id, job.status, job.percent_complete)

Restore-job pagination is offset-based in the same way as backup pagination above, so the same rules apply: pagination is None on the final page, limit is omitted whenever a token is given, and a malformed token is a 400.

Warning

Against today’s backend this listing can silently drop restore jobs, stop paginating early, and repeat rows across pages. The token stream can end while restore jobs remain, and successive pages can overlap, so pages are neither exhaustive nor disjoint. A restore job whose target index has been deleted is dropped from the listing entirely, which shortens the page and can itself trigger the early stop.

Treat the result as a best-effort sample rather than an exhaustive inventory, never conclude a restore job does not exist from its absence here, and de-duplicate by restore_job_id while walking pages. The SDK offers no workaround on purpose. The token stream itself ends early, so no client-side code can recover pages the server never points at. Tracked upstream in #250.

Describe a specific job:

job = pc.restore_jobs.describe(job_id="rj-xyz789")
print(job.restore_job_id)
print(job.backup_id)
print(job.target_index_name)
print(job.status)         # "Pending" | "Completed" | "Failed" | "Cancelled"
print(job.percent_complete)
print(job.completed_at)

describe returns a RestoreJobModel.

status has exactly four values: Pending, Completed, Failed, and Cancelled. There is no in-progress state. A restore that is actively running reports Pending, so polling for a Running-style value never terminates. percent_complete is 100 once status is Completed and None at every other point. It reports completion, not progress, and cannot drive a progress bar. completed_at is populated on the same condition. Poll status for a terminal value instead:

import time

while True:
    job = pc.restore_jobs.describe(job_id="rj-xyz789")
    if job.status in ("Completed", "Failed", "Cancelled"):
        break
    time.sleep(5)

Warning

A 404 from describe cannot be trusted to mean “no such restore job”. Against today’s backend, every failure to read the restore-job store, a store outage included, is flattened into a 404, so the NotFoundError you catch here means “could not produce this job”, not “this job does not exist”. Any retry policy or control flow keyed on a 404 from describe is therefore unsafe: giving up, deleting local state, or reporting the job as gone can each be the wrong call on what was really a transient store failure. Treat it as possibly transient unless you have independent evidence the id is bad.

The same flattening means a restore job whose target index has been deleted also answers 404, carrying a message about index metadata rather than “Restore job not found”, so do not match on the message text either. Such a job is dropped from list entirely rather than reported. Tracked upstream in #250.

Delete a backup

pc.backups.delete(backup_id="bk-abc123")

Deleting a backup does not affect the source index or any indexes restored from it. The call returns None. The API answers 202 Accepted with no body.

A backup with a restore job still in flight cannot be deleted; the API returns 412 and the SDK raises a FailedPreconditionError naming the pending job ids. Wait for the restore to finish, then delete.

Schedule automatic backups

Everything above takes a backup when you ask for one. A backup schedule attaches a recurring cadence to an index, so backups keep happening without a caller. Schedules live on pc.backup_schedules.

Scheduled backups are a plan entitlement. Where the API enforces it, the check runs before the index is even looked up, so a project without the entitlement gets 403 rather than a 404 for a missing index. The SDK appends a clarification to that 403 and keeps the backend’s own message as the prefix.

schedule = pc.backup_schedules.create(
    index_name="product-search",
    name="daily-compliance-backup",
    frequency="daily",       # daily | weekly | monthly
    retention_days=90,
)
print(schedule.schedule_id)
print(schedule.next_scheduled_run)   # a datetime, not a string

There is no cron support anywhere in this API. frequency accepts exactly daily, weekly, or monthly, and the SDK rejects anything else before sending a request. The run time is chosen server-side and reported through next_scheduled_run; there is no way to pick an hour or a timezone.

retention_days must be at least 1. Its upper bound is a per-project setting (max_backup_retention_days) that the SDK does not know, so a too-large value is rejected by the server with a message naming the real limit.

Only one enabled schedule per index is allowed. Creating a second one fails with 409 and a message telling you to disable or delete the first. Pod-based indexes cannot be scheduled at all and are rejected with 400.

Important

Keep the schedule name short. Each run names its backup "{name}-{run timestamp}", so a long schedule name pushes the derived backup name past the length limit backup names are held to. Nothing checks this at create time. The API declares no length limit on a schedule name, and the SDK does not invent one, because that would reject names the API accepts. An over-long name is therefore accepted here and fails later, on the runs, rather than on create.

List the schedules on an index

Schedules are always listed per index. There is no project-wide schedule listing. Disabled schedules are included.

for schedule in pc.backup_schedules.iter_schedules(index_name="product-search"):
    print(schedule.schedule_id, schedule.frequency, schedule.enabled)

iter_schedules walks every page. list returns a single page plus a pagination token if you would rather drive pagination yourself:

page = pc.backup_schedules.list(index_name="product-search", limit=10)
print(page.names())
print([s.schedule_id for s in page.enabled_schedules()])
print(page.pagination)   # None on the final page

Describe, update, and delete a schedule

schedule = pc.backup_schedules.describe(schedule_id="sched-abc123")

update is a sparse PATCH. Only the arguments you pass are sent, so anything you omit is left unchanged rather than reset.

paused = pc.backup_schedules.update(schedule_id="sched-abc123", enabled=False)
assert paused.next_scheduled_run is None

pc.backup_schedules.update(
    schedule_id="sched-abc123", frequency="weekly", retention_days=30
)

Warning

Re-enabling a disabled schedule with enabled=True immediately enqueues a backup run. It is not a free toggle. It also recomputes next_scheduled_run from the moment of the update rather than resuming the old slot, so a disable/re-enable cycle shifts the cadence. And because only one schedule per index may be enabled, re-enabling fails with 409 when another one already is.

pc.backup_schedules.delete(schedule_id="sched-abc123")

Deleting a schedule stops future runs. Backups it already produced are not deleted; they age out on their own retention window.

Important

delete is not safe to retry blindly. Success answers 204 with no body, and a second attempt on the same schedule_id answers 404, so a retry after a dropped response looks identical to deleting something that was never there. Treat a 404 following a delete attempt as success.

Inspect what a schedule has produced

for run in pc.backup_schedules.iter_history(schedule_id="sched-abc123"):
    print(run.backup_id, run.status, run.record_count)

History rows describe backup snapshots, not the schedule. A row appears as soon as a run is planned, so the listing mixes completed runs with ones that have not started; run.is_scheduled and history.scheduled() pick out the latter. As with the schedule listing, history returns one page and iter_history walks all of them. Prefer the iterator here, because a daily schedule with a 90-day retention window has far more rows than one page holds.

Note

Against today’s backend, schedule history is served by the shared backup handler, which never reports the Scheduled status and does not send scheduled_execution_at at all. Those fields are typed and will populate when the backend graduates; until then scheduled_execution_at reads as None and name / record_count / namespace_count / size_bytes can come back None on freshly created rows.

See also