Firebase to Postgres Migration: A Practical Plan

A Firebase to Postgres migration has four parts: design tables for your Firestore collections, export the data and load it, move your Firebase Auth users, and rewrite Security Rules as Row Level Security policies. Unlike a Postgres-to-Postgres move, there is no one-click copy, because a document database and a relational database store data differently. The work is real, but it is predictable if you do it in that order.

This guide is a plan you can follow. It uses StitchDB as the target in the examples, but every step applies to any Postgres backend.

#Why teams start a Firebase to Postgres migration

The usual reasons are concrete:

  • Queries Firestore makes hard. Joins, aggregates across collections, ad-hoc reporting and full-text search all need workarounds or extra services in Firestore. In Postgres they are a SQL query.
  • Data integrity. Foreign keys, unique constraints, check constraints and transactions across tables are built in.
  • Cost predictability. Firestore bills per document read, write and delete. Postgres hosting is usually billed by size and compute, which is easier to forecast for read-heavy apps.
  • Portability. A Postgres dump restores on any Postgres server.

There are also good reasons to stay. Firestore's offline sync on mobile is mature, its real-time listeners are simple, and it scales writes without you thinking about servers. If your app is mostly per-user documents with little cross-user querying, the migration may not pay for itself.

#Phase 1: Model documents as tables

Do this on paper before you write any code. For each collection, decide:

  1. Which fields become columns. Fields that every document has, and that you filter or sort on, become typed columns.
  2. Which nested data becomes its own table. Arrays of objects (order line items, comments) usually become a child table with a foreign key.
  3. What stays as jsonb. Truly free-form data (settings blobs, third-party payloads) can stay as a jsonb column. Do not put everything in jsonb: you lose the reason you migrated.
  4. How document references map. A Firestore reference becomes a foreign key column.

A typical example. In Firestore:

JSON
{
  "users/u_8f2": { "displayName": "Ada", "plan": "pro" },
  "orders/o_19": {
    "userId": "u_8f2",
    "status": "paid",
    "createdAt": "2026-10-01T09:12:00Z",
    "items": [
      { "sku": "TSHIRT-M", "qty": 2, "price": 1900 },
      { "sku": "MUG", "qty": 1, "price": 1200 }
    ]
  }
}

In Postgres:

SQL
create table public.profiles (
  id uuid primary key references auth.users on delete cascade,
  firebase_uid text unique not null,
  display_name text,
  plan text not null default 'free'
);

create table public.orders (
  id bigint generated always as identity primary key,
  firebase_id text unique,
  user_id uuid not null references public.profiles(id),
  status text not null check (status in ('pending', 'paid', 'refunded')),
  created_at timestamptz not null default now()
);

create table public.order_items (
  order_id bigint not null references public.orders(id) on delete cascade,
  sku text not null,
  qty int not null check (qty > 0),
  price_cents int not null,
  primary key (order_id, sku)
);

create index on public.orders (user_id, created_at desc);

Keep the old Firestore document ids in a firebase_id column. You will need them to resolve references during the load, and they make reconciliation much easier.

#Subcollections

A subcollection like users/{uid}/notes/{noteId} is just a child table with a user_id column. Collection group queries in Firestore become ordinary queries on that table.

#Timestamps and numbers

Firestore timestamps map to timestamptz. Store money as integer cents, not floats. Firestore numbers are doubles or 64-bit integers; check which one each field really holds before you choose int, bigint or numeric.

#Phase 2: Export the data

You have two main options.

Managed export. gcloud firestore export gs://<bucket> writes the database to a Cloud Storage bucket. According to Google's export and import docs, the files use the LevelDB log format, and the project must be on the Blaze plan. Exports made with a --collection-ids filter can be loaded into BigQuery, and from there you can export JSON or CSV. This route is good for large databases.

A script with the Admin SDK. For small and medium databases, a Node.js script that pages through each collection and writes newline-delimited JSON is often simpler. You control the shape of the output and can flatten as you go:

JavaScript
// export-orders.js (runs with the Firebase Admin SDK)
import { initializeApp, applicationDefault } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { createWriteStream } from 'node:fs'

initializeApp({ credential: applicationDefault() })
const db = getFirestore()
const out = createWriteStream('orders.ndjson')

let last = null
while (true) {
  let q = db.collection('orders').orderBy('__name__').limit(1000)
  if (last) q = q.startAfter(last)
  const snap = await q.get()
  if (snap.empty) break
  for (const doc of snap.docs) {
    out.write(JSON.stringify({ id: doc.id, ...doc.data() }) + '\n')
  }
  last = snap.docs[snap.docs.length - 1]
}
out.end()

Note that a script export reads every document, which Firestore bills as document reads.

#Phase 3: Load into Postgres

Load in dependency order: parents first, then children. A simple, reliable approach is a staging table plus SQL:

SQL
create schema if not exists staging;
create table staging.orders_raw (doc jsonb);
-- load orders.ndjson into staging.orders_raw with \copy or your loader

insert into public.orders (firebase_id, user_id, status, created_at)
select
  r.doc ->> 'id',
  p.id,
  r.doc ->> 'status',
  (r.doc ->> 'createdAt')::timestamptz
from staging.orders_raw r
join public.profiles p on p.firebase_uid = r.doc ->> 'userId';

insert into public.order_items (order_id, sku, qty, price_cents)
select o.id, i ->> 'sku', (i ->> 'qty')::int, (i ->> 'price')::int
from staging.orders_raw r
join public.orders o on o.firebase_id = r.doc ->> 'id'
cross join lateral jsonb_array_elements(r.doc -> 'items') as i;

Constraints do useful work here. A failed check or foreign key tells you about bad data that Firestore silently accepted. Fix it at the source or in the staging step, not by dropping the constraint.

Connect over session mode (port 5432) for loads like this. See Connect to Postgres.

#Phase 4: Move Firebase Auth users

firebase auth:export users.json exports your users. Firebase's CLI docs note that Firebase hashes passwords with its own modified version of scrypt, and the hash parameters are shown in the console.

StitchDB's documented import keeps password hashes when the source is another Postgres backend of the same stack, but it does not document importing Firebase scrypt hashes. So plan for one of these:

  • Passwordless first sign-in. Create each user with the admin API (service role key, on a server), with email_confirm: true and no password. Ask users to sign in with a magic link or code, then let them set a new password. See Passwordless sign-in.
  • Password reset campaign. Create the users and send each one a reset e-mail. Watch your plan's e-mail limits and use your own SMTP server for large lists.
JavaScript
import { createClient } from 'stitchdb-js'

const admin = createClient(
  'https://<project-ref>.p.stitchdb.com',
  process.env.STITCHDB_SERVICE_ROLE_KEY
)

for (const u of firebaseUsers) {
  const { data, error } = await admin.auth.admin.createUser({
    email: u.email,
    email_confirm: u.emailVerified,
  })
  if (error) { console.error(u.localId, error.message); continue }
  // map the old Firebase uid to the new user id
  await admin.from('profiles').insert({
    id: data.user.id,
    firebase_uid: u.localId,
    display_name: u.displayName ?? null,
  })
}

New user ids are new uuids, which is why the firebase_uid column matters: it is how you rewrite every userId reference during the data load. Social sign-ins (Google, Apple, GitHub) need the provider configured on the new backend; users then sign in with the same provider and you match them by e-mail.

#Phase 5: Turn Security Rules into RLS

Firestore Security Rules and Postgres Row Level Security answer the same question: may this user read or write this row? The translation is usually direct.

Firestore:

Text
match /orders/{orderId} {
  allow read: if request.auth.uid == resource.data.userId;
}

Postgres:

SQL
alter table public.orders enable row level security;

create policy "Users read their own orders" on public.orders
  for select to authenticated
  using (user_id = auth.uid());

Enable RLS on every table in public. On StitchDB, RLS Guard turns it on for new tables by default on new projects, and the Row Level Security docs explain the Check access tool, which tells you in plain words why a user can or cannot see a row. For a deeper dive, read Postgres Row Level Security: A Practical Guide.

#Phase 6: Replace client calls

Firestore client calls become REST queries through the SDK:

JavaScript
// Firestore
const q = query(collection(db, 'orders'), where('status', '==', 'paid'), orderBy('createdAt', 'desc'), limit(20))

// Postgres via the SDK
const { data } = await stitchdb
  .from('orders')
  .select('id, status, created_at, order_items(sku, qty, price_cents)')
  .eq('status', 'paid')
  .order('created_at', { ascending: false })
  .limit(20)

Real-time listeners become Postgres Changes subscriptions. Cloud Functions triggers become database triggers, Edge Functions or cron jobs.

#Phase 7: Cut over

Without a live sync between Firestore and Postgres, the cleanest cut-over is:

  1. Ship an app version that can talk to the new backend, behind a flag.
  2. Freeze writes (maintenance mode or a read-only flag).
  3. Run a final incremental export of documents changed since the first export (filter on an updatedAt field if you have one).
  4. Load, reconcile counts per collection against table counts, then flip the flag.

If you cannot afford a write freeze, the alternative is dual writes for a period, which is more code and more risk. Most apps are better served by a short, announced maintenance window.

#FAQ

#Is there an automatic Firestore to Postgres converter?

Not one that gives you a good schema. Tools can dump documents into jsonb, but you should still design real tables for the fields you query.

#Can users keep their Firebase passwords?

Not with any documented StitchDB feature today. Plan for magic links or a password reset on first sign-in.

#How do I handle Firestore offline sync?

Postgres has no built-in client cache like Firestore's. StitchDB has an offline sync feature for selected tables; check whether it covers your use case before you remove Firestore.

#How long does a migration take?

For a small app, a few days of focused work. The schema design and auth plan take the most thought; the data load itself is usually quick.

Want a Postgres backend with Auth, Storage and Realtime in one place? Start free on StitchDB, or compare options in Supabase vs Firebase.