# Migrate from Firestore

> Move Firestore collections into Postgres tables on SupaHost: design the schema, export documents with the Admin SDK, flatten them, load them in batches and switch your queries.

Source: https://supahost.app/kb/migrate-from-firestore · Updated 2026-11-11

Moving from Firestore to SupaHost means turning collections of documents into Postgres tables. There's no automatic converter: you design the tables, export the documents with the Firebase Admin SDK, reshape them in a script, and insert them through the Data API with the `service_role` key. For most apps this is a few hundred lines of code and a careful afternoon.

## Plan the schema

Map each collection to a table and each subcollection to a child table with a foreign key. Fields you always read become columns; loose or rarely queried data can go in a `jsonb` column.

For a recipe app with `recipes/{id}` and `recipes/{id}/steps/{id}`:

```sql
create table public.recipes (
  id uuid primary key default gen_random_uuid(),
  firestore_id text unique not null,
  owner_id uuid references auth.users (id),
  title text not null,
  tags text[] default '{}',
  extra jsonb default '{}',
  created_at timestamptz default now()
);

create table public.recipe_steps (
  id bigint generated always as identity primary key,
  recipe_id uuid not null references public.recipes (id) on delete cascade,
  position int not null,
  body text not null
);

alter table public.recipes enable row level security;
alter table public.recipe_steps enable row level security;
```

Keeping `firestore_id` makes the load repeatable and lets you join old references. Migrate Auth users first ([Migrate from Firebase Auth](/kb/migrate-from-firebase-auth)) so you can turn Firebase UIDs into Supabase user IDs.

## Export from Firestore

```ts
import { initializeApp, cert } from 'firebase-admin/app'
import { getFirestore } from 'firebase-admin/firestore'
import { writeFileSync } from 'node:fs'

initializeApp({ credential: cert('./service-account.json') })
const db = getFirestore()

const snap = await db.collection('recipes').get()
const rows = []
for (const doc of snap.docs) {
  const steps = await doc.ref.collection('steps').orderBy('position').get()
  rows.push({ id: doc.id, ...doc.data(), steps: steps.docs.map((s) => s.data()) })
}
writeFileSync('recipes.json', JSON.stringify(rows))
```

Convert Firestore `Timestamp` values with `.toDate().toISOString()`, and turn references into IDs, before writing the file.

## Transform and load

Build a map from Firebase UID to the new user ID (the Firebase Auth guide stores `firebase_uid` in `user_metadata`), then insert in batches:

```ts
import { createClient } from '@supabase/supabase-js'

const admin = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)

for (let i = 0; i < rows.length; i += 500) {
  const batch = rows.slice(i, i + 500)
  const { data, error } = await admin
    .from('recipes')
    .upsert(
      batch.map((r) => ({
        firestore_id: r.id,
        owner_id: uidMap.get(r.ownerUid) ?? null,
        title: r.title,
        tags: r.tags ?? [],
        extra: { servings: r.servings },
      })),
      { onConflict: 'firestore_id' }
    )
    .select('id, firestore_id')
  if (error) throw error
  // then insert recipe_steps using the returned ids
}
```

`upsert` with `onConflict` means you can rerun the script after a failure. Responses return at most 1000 rows, so keep batches under that. On Business you can instead write CSV files and load them with `\copy` through the SSH tunnel, which is faster for millions of rows.

## Rewrite security rules as RLS

Firestore security rules become RLS policies:

```sql
create policy "owners read their recipes" on public.recipes
  for select to authenticated using ((select auth.uid()) = owner_id);
```

## Switch the app

| Firestore | supabase-js |
|---|---|
| `getDocs(query(col, where('owner', '==', uid)))` | `supabase.from('recipes').select().eq('owner_id', uid)` |
| `addDoc(col, data)` | `supabase.from('recipes').insert(data)` |
| `onSnapshot(...)` | `supabase.channel('recipes').on('postgres_changes', ...)` |

## Verify

Compare document counts with `select count(*) from public.recipes`, spot-check a few records, and test as a signed-in user to confirm the policies return the right rows.

## Related

- [Tables, columns and data](/docs/database/tables)
- [JSON and JSONB columns](/docs/database/json)
- [Row Level Security](/docs/database/row-level-security)
- [Postgres Changes](/docs/realtime/postgres-changes)
