Published  · Updated

Firestore Backup and Restore: Schedules, PITR and File Exports

Set up Firestore backups, point-in-time recovery and delete protection. Follow gcloud commands and a Node file backup script, then compare recovery workflows in Chiselbase.

By Chiselbase  · Explore the Firestore GUI: Firestore

Set up Firestore recovery before you need it

You can prepare for accidental changes with delete protection, point-in-time recovery (PITR), scheduled backups and managed exports. Set up the protection or create the backup before you need it: enabling PITR does not create seven days of history retroactively. Check the database’s earliestVersionTime for the oldest available moment. Restoring a backup or cloning from a point in time creates a new database, leaving the current database in place.

This guide sets up Google’s recovery options with gcloud, shows a readable file extract and explains which parts Chiselbase brings into the desktop workspace.

Keep recovery settings beside your data. Chiselbase Free lets you manage delete protection, PITR and backup schedules. Pro adds restore and clone workflows. For supported changes made in the app, local Undo can be the shortest route back. See the recovery workflow.

Way 1: Google’s tools with gcloud

When a command takes --database, quote (default). The parentheses mean something to your shell. Restore and clone use separate source and destination flags.

Use a current Google Cloud CLI in Bash or Google Cloud Shell. PowerShell does not use \ for line continuation; put each command on one line there. Replace uppercase placeholders and example dates. Clone and PITR export timestamps must be in the past and no earlier than the database’s earliestVersionTime.

Backups and PITR require billing (the Blaze plan). Your caller needs IAM permissions for each operation: roles/datastore.backupSchedulesAdmin for schedules, roles/datastore.backupsViewer for listing backups and roles/datastore.restoreAdmin for restoring. Updating database settings and cloning need their own database permissions; roles/datastore.owner covers them. The backup prerequisites and PITR guide explain the roles.

Stop the database from being deleted

Terminal window
gcloud config set project PROJECT_ID
gcloud firestore databases update --database="(default)" --delete-protection

This blocks deleting the whole database. It does not stop anyone from deleting documents or collections.

Turn on point-in-time recovery

Terminal window
gcloud firestore databases update --database="(default)" --enable-pitr

With PITR, Firestore keeps 7 days of versions, readable to the minute. Without it, you can read the last hour. PITR storage is billed. The window starts filling when you switch it on.

Schedule backups

Terminal window
# Daily, kept for 7 days
gcloud firestore backups schedules create --database="(default)" \
--recurrence=daily --retention=7d
# Weekly, kept for up to 14 weeks
gcloud firestore backups schedules create --database="(default)" \
--recurrence=weekly --day-of-week=SUN --retention=14w

Firestore allows one daily and one weekly schedule per database. Retention can be up to 14 weeks for either schedule. You cannot choose an exact time of day. The first backup appears after the first run, not when you create the schedule. Backup storage and restore operations are billed separately.

Restore a backup or clone a point in time

Both create a new database. Your live database stays untouched.

Terminal window
# Find the backup
gcloud firestore backups list --format="table(name, database, state)"
# Restore it into a new database
gcloud firestore databases restore \
--source-backup=projects/PROJECT_ID/locations/LOCATION/backups/BACKUP_ID \
--destination-database=restored-2026-09-30
# Or clone the database as it was at a minute inside the PITR window
gcloud firestore databases clone \
--source-database="projects/PROJECT_ID/databases/(default)" \
--snapshot-time=2026-09-30T08:00:00Z \
--destination-database=before-the-delete

Getting lost documents back into (default) is a second step: copy them from the restored database, for example with a Chiselbase migration between databases or an SDK script configured for that database. The file script below uses (default) unless you adapt getFirestore(). Restore and clone operations incur charges, and the new database costs storage until you delete it.

Back up to a bucket with the managed export

Terminal window
gcloud firestore export gs://BUCKET/backups/2026-09-30 --database="(default)"
# Import writes into the existing database. Documents with the same ID are overwritten,
# documents that are not in the export stay as they are.
gcloud firestore import gs://BUCKET/backups/2026-09-30 --database="(default)"

This needs the Blaze plan, a bucket, the role roles/datastore.importExportAdmin and a Cloud Storage role on the bucket (Google’s export page names Owner or Storage Admin). The files are binary. The details are in how to export Firestore to JSON, CSV or JSONL.

Way 2: a backup file you can read, with a Node script

Google’s backups live in Google’s format. If you want a copy on your own disk that you can search, the script below walks top-level collections and subcollections beneath existing documents. It writes one tagged JSON line per document. Tags preserve timestamps, references, geopoints, bytes, vectors and special numbers; JavaScript numeric precision and missing parent documents remain limitations.

Use Node.js 22 or later; the example was checked with firebase-admin 14.5.0. Save it as firestore-backup.mjs. The restore defaults to a parsing dry run; add --write only after reviewing the file and target. Keep service account keys outside the repository.

Terminal window
npm init -y
npm install firebase-admin
export GOOGLE_APPLICATION_CREDENTIALS="$HOME/keys/my-project-backup.json"

The key needs roles/datastore.viewer for a backup and roles/datastore.user for a restore. On Windows, set the variable with $env:GOOGLE_APPLICATION_CREDENTIALS = "C:\keys\my-project-backup.json".

Show firestore-backup.mjs — complete script
firestore-backup.mjs
// Usage: node firestore-backup.mjs backup <file.jsonl>
// node firestore-backup.mjs restore <file.jsonl> [--write]
import { createReadStream } from "node:fs";
import { open } from "node:fs/promises";
import { createInterface } from "node:readline";
import { initializeApp, applicationDefault } from "firebase-admin/app";
import {
getFirestore,
FieldPath,
Timestamp,
GeoPoint,
DocumentReference,
FieldValue,
} from "firebase-admin/firestore";
const PAGE_SIZE = 300;
const FLUSH_EVERY = 2000;
const VectorValue = FieldValue.vector([]).constructor;
const [command, file, flag, ...extra] = process.argv.slice(2);
if (!["backup", "restore"].includes(command) || !file || extra.length || (flag && (command !== "restore" || flag !== "--write"))) {
console.error("Usage: node firestore-backup.mjs <backup|restore> <file.jsonl> [--write]");
process.exit(1);
}
const WRITE = flag === "--write";
initializeApp({ credential: applicationDefault() });
const db = getFirestore();
// Tags preserve special values without confusing them with user maps.
function encode(value) {
if (typeof value === "number" && !Number.isFinite(value)) return { $t: "number", v: String(value) };
if (value === null || typeof value !== "object") return value;
if (value instanceof Timestamp) return { $t: "ts", s: value.seconds, n: value.nanoseconds };
if (value instanceof GeoPoint) return { $t: "geo", lat: value.latitude, lng: value.longitude };
if (value instanceof DocumentReference) return { $t: "ref", path: value.path };
if (value instanceof Uint8Array) return { $t: "bytes", b64: Buffer.from(value).toString("base64") };
if (value instanceof VectorValue) return { $t: "vector", v: value.toArray() };
if (Array.isArray(value)) return value.map(encode);
return { $t: "map", v: Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encode(v)])) };
}
function decode(value) {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(decode);
switch (value.$t) {
case "number": return Number(value.v);
case "ts": return new Timestamp(value.s, value.n);
case "geo": return new GeoPoint(value.lat, value.lng);
case "ref": return db.doc(value.path);
case "bytes": return Buffer.from(value.b64, "base64");
case "vector": return FieldValue.vector(value.v);
case "map": return Object.fromEntries(Object.entries(value.v).map(([k, v]) => [k, decode(v)]));
default: throw new Error(`Unknown value in backup: ${JSON.stringify(value)}`);
}
}
async function backupCollection(ref, write, stats) {
let last = null;
for (;;) {
let query = ref.orderBy(FieldPath.documentId()).limit(PAGE_SIZE);
if (last) query = query.startAfter(last);
const page = await query.get();
for (const doc of page.docs) {
await write(`${JSON.stringify({ path: doc.ref.path, data: encode(doc.data()) })}\n`);
stats.documents += 1;
// One extra request per document to find its subcollections.
for (const sub of await doc.ref.listCollections()) await backupCollection(sub, write, stats);
}
if (page.size < PAGE_SIZE) return;
last = page.docs[page.docs.length - 1];
}
}
if (command === "backup") {
const out = await open(file, "wx"); // refuse to overwrite an existing backup
const write = (text) => out.writeFile(text, "utf8");
const stats = { documents: 0 };
try {
for (const collection of await db.listCollections()) {
console.log(`Backing up ${collection.path} …`);
await backupCollection(collection, write, stats);
}
} finally {
await out.close();
}
console.log(`${stats.documents} documents written to ${file}.`);
} else {
const writer = WRITE ? db.bulkWriter() : null;
let failed = 0;
// A custom handler replaces BulkWriter's default, which retries ABORTED and
// UNAVAILABLE up to 10 times. Keep that, and fail every other error at once.
const RETRYABLE = new Set([10, 14]); // gRPC status codes ABORTED, UNAVAILABLE
writer?.onWriteError((error) => {
if (RETRYABLE.has(error.code) && error.failedAttempts < 10) return true;
failed += 1;
console.error(`Failed: ${error.documentRef.path}: ${error.message}`);
return false;
});
const input = createReadStream(file, { encoding: "utf8" });
const lines = createInterface({ input, crlfDelay: Infinity });
let queued = 0;
try {
for await (const line of lines) {
if (!line.trim()) continue;
const { path, data } = JSON.parse(line);
if (typeof path !== "string" || data?.$t !== "map") throw new Error("Expected a document path and tagged map");
const ref = db.doc(path);
const fields = decode(data);
// set() replaces the document. Failures are counted in onWriteError above.
writer?.set(ref, fields).catch(() => {});
queued += 1;
if (writer && queued % FLUSH_EVERY === 0) await writer.flush();
}
} finally {
lines.close();
input.destroy();
if (writer) await writer.close();
}
console.log(WRITE
? `${queued - failed} of ${queued} documents restored, ${failed} failed.`
: `Dry run: ${queued} documents parsed. Nothing written. Add --write to restore.`);
if (failed) process.exitCode = 1;
}
Terminal window
node firestore-backup.mjs backup backup-2026-09-30.jsonl
node firestore-backup.mjs restore backup-2026-09-30.jsonl
node firestore-backup.mjs restore backup-2026-09-30.jsonl --write

Try a restore against a running local emulator first. In Bash:

Terminal window
unset GOOGLE_APPLICATION_CREDENTIALS
export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
export GCLOUD_PROJECT=demo-restore
node firestore-backup.mjs restore backup-2026-09-30.jsonl
node firestore-backup.mjs restore backup-2026-09-30.jsonl --write

In PowerShell:

Terminal window
Remove-Item Env:GOOGLE_APPLICATION_CREDENTIALS -ErrorAction SilentlyContinue
$env:FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080"
$env:GCLOUD_PROJECT = "demo-restore"
node firestore-backup.mjs restore backup-2026-09-30.jsonl
node firestore-backup.mjs restore backup-2026-09-30.jsonl --write

Unset FIRESTORE_EMULATOR_HOST when you are done. While it is set, every Firestore instance in the process connects to the emulator.

Costs and limits of this script:

  • A backup bills document reads, empty-query minimums and collection-list requests. Each request listing collection IDs is billed as a document read, including extra pages.
  • The restore costs one write per document and overwrites documents with the same path.
  • Backup output must not already exist. On failure, remove the partial file or choose a new filename and retry. A restore is not atomic; earlier writes remain if a later line fails. Failed server writes set a non-zero exit code.
  • A restore dry run validates the file’s paths and tagged values locally; it does not inspect the target or verify server permissions.
  • Integers and doubles both arrive as JavaScript numbers. A double like 3.0 comes back as the integer 3. Integers above 2⁵³ lose precision.
  • Queries only return documents that exist. A subcollection under a document that doesn’t exist (a “ghost” parent) is never visited, so its documents are missing from the backup.
  • This is not a consistent snapshot while writes continue, and it does not back up indexes, Security Rules, TTL policies or other Firebase services. Use a scheduled backup or a PITR export for database recovery.
  • Reference tags store document paths. On restore they resolve in the destination database, including references that originally pointed into a different project or database. Adapt the tag to store the full resource name if you need those external references preserved.

To pull single documents out of a restored database into the live one, point a second Firestore instance at it with getFirestore(app, "restored-2026-09-30").

You can also read a document as it was, without restoring anything. Inside the last hour this works without PITR. Further back it needs PITR and a whole minute:

read-at.mjs
import { initializeApp, applicationDefault } from "firebase-admin/app";
import { getFirestore, Timestamp } from "firebase-admin/firestore";
initializeApp({ credential: applicationDefault() });
const db = getFirestore();
const readTime = Timestamp.fromMillis(Date.now() - 30 * 60 * 1000); // 30 minutes ago
const before = await db.runTransaction((tx) => tx.get(db.doc("orders/ord_10384")), {
readOnly: true,
readTime,
});
console.log(before.exists ? before.data() : "The document did not exist at that time.");

Way 3: with Chiselbase

Chiselbase puts Google’s recovery settings into one view beside your collections. Recovery-setting changes and initiated restore or clone operations appear in its audit log. Google’s billing and IAM requirements still apply, including when the Chiselbase feature is Free.

Set up recovery (Free). Open Firestore → Backups. There you can:

  • turn on Delete protection,
  • turn on Point-in-time recovery (PITR),
  • create Backup schedules with Create schedule.

These three are part of Chiselbase Free, on your one cloud project.

· backups · (default)

Backups view of the (default) database. Delete protection is on, with the note that it blocks deleting the database, also via gcloud or Terraform. Point-in-time recovery is on, recoverable from 22 September 2026, with Clone and Turn off buttons. Two backup schedules: daily, kept 2 weeks, and weekly on Sunday, kept 13 weeks. Below, 6 ready backups from 13 to 29 September 2026, each with a Restore button.

Delete protection and PITR on, two backup schedules, six backups. Recovery settings, scheduled backups and restore actions share one view.

Restore (Pro). From the same view, Restore from backup and Clone to a point in time create a new database, as Google’s API always does. Time travel reads a document as it was at a point in the PITR window, without writing anything. Exporting a collection to FS-JSON with its subcollections, and importing that file again, is also Pro. Restoring is part of Chiselbase Pro; the 14-day trial starts inside the app.

Restore supported Chiselbase changes with Undo (Free). Chiselbase attempts to save a local snapshot for Firestore document edits and deletes, completed bulk runs and whole-collection deletes. Undo requires a successfully saved snapshot; a collection delete still runs if its snapshot fails. Moving or copying documents and changes made outside Chiselbase have no undo. Metadata-only and none policies disable undo. See audit and undo coverage for the full limits.

Two things also help before anything goes wrong. A new production connection starts write-protected. And production asks you to type the project ID before a destructive action. Details are on the safety page.

Which way to pick

Recovery option What it covers Recovery and cost
Delete protection Accidental deletion of the entire database No recovery operation or extra protection charge. Does not protect documents.
PITR A retained moment within up to 7 days Historical reads, clone to a new database or PITR export. Version storage, reads and clone charges apply.
Scheduled backup The state at a daily or weekly backup, retained up to 14 weeks Restore to a new database. Backup storage and restore are billed.
Managed export Documents in the exported database or selected groups Import into an existing database. Document reads/writes and bucket storage are billed.
Tagged script extract A readable copy of documents reachable by this script Restore into an existing database. Read/write charges; not a complete or consistent database backup.
Chiselbase Google’s recovery options in one view, plus local Undo for supported app changes Settings and Undo are Free; restore, clone and file export/import are Pro. Google’s charges still apply.

Start with delete protection, then choose PITR and a backup schedule according to how much history you need and your recovery budget. A readable extract is useful for inspection, with the script’s limits above. Chiselbase brings Google’s controls into the same workspace and adds local Undo for supported changes made in the app.

Limits and questions

Can I restore a single collection from a scheduled backup?

Not directly. A restore always creates a whole new database. Restore it, then copy the collection you need into (default) with a script or with a migration between the two databases.

Does PITR cost extra?

Yes. Firestore bills the storage for the version history. The size depends on how much your data changes, so check the pricing page for your region.

Is a managed export a consistent snapshot?

Not by default. Writes during the export may or may not be included. With PITR on, gcloud firestore export --snapshot-time exports one consistent moment.

I deleted a collection a minute ago. What now?

If PITR is on, or the delete is less than an hour old, clone the database to the minute before and copy the collection back. If you deleted it in Chiselbase, try Undo from the Audit log first.

To put those controls next to the data you work on, download Chiselbase. Recovery settings and supported local Undo are Free; start the 14-day Pro trial in the app when you want to restore, clone or export a file. See Free and Pro for the plan limits.

Sources and verification

Checked on 4 October 2026 against Google’s backup documentation, PITR guide, clone CLI reference and pricing. The file script was exercised against a local emulator. Cloud commands and historical-read options were checked against the official references; a local emulator does not verify cloud recovery behavior. Chiselbase steps were checked against the current implementation.

Next: how to copy Firestore data between projects. What else Chiselbase does with Firestore is on the Firestore page.