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 Initializing → Ready 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:
limitis omitted whenever a token is given. The token already carries the page size it was minted with, and sending a differentlimitalongside 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_idrather than trusting the sequence.A malformed or truncated token is rejected with
400rather 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 readsBackup {backup_id} is not completed, naming the store’s rawCompletedstatus rather than theReadystatus the SDK surfaces onBackupModel, so a412here means “statusis not yetReady”, 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¶
BackupModel: backup response modelBackupList: backup list responseRestoreJobModel: restore job modelRestoreJobList: restore job list responseBackupScheduleModel: schedule response modelBackupScheduleList: schedule list responseBackupScheduleHistoryItem: one run produced by a scheduleBackupScheduleHistoryList: schedule history responseWorking with Serverless Indexes: serverless index management
Working with pod-based indexes: pod-based index management