Migrating from NoSQL to Postgres: the guide
7 July 2026· 6 min read · by Stackbastion
A NoSQL backend-as-a-service platform got your app off the ground fast. Now the bill climbs with every read, the queries you need aren’t possible, and you want real SQL. Moving from a document database to Postgres is a real project, but it’s a well-worn path. Here’s how to do it without dropping data on the floor.
Why the move is more than a data copy
A NoSQL document database stores nested JSON-like documents in collections. Postgres is relational: rows, columns, tables, and foreign keys. You’re not just copying data, you’re reshaping it.
That reshaping is the hard part. A document can nest arrays and sub-objects three levels deep. In Postgres you decide: does that nested data become its own table, or a jsonb column? Get this wrong and you’ll fight your schema for months. Get it right and your queries get faster and cheaper.
Step 1: export your documents
First, get everything out. The platform’s managed export tools typically drop your data to a storage bucket, but that format is meant for re-importing into the same platform, not for reading. For a migration you want readable JSON.
The cleaner path is a small script using the platform’s server-side admin SDK to walk every collection and write JSON files:
import platformAdmin from "your-platform-admin-sdk";
platformAdmin.initializeApp({ credential: platformAdmin.credential.applicationDefault() });
const db = platformAdmin.documentStore();
async function exportCollection(name) {
const snapshot = await db.collection(name).get();
const docs = snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
await Bun.write(`export/${name}.json`, JSON.stringify(docs, null, 2));
console.log(`${name}: ${docs.length} docs`);
}
await exportCollection("users");
await exportCollection("orders");
Run this for every top-level collection. For sub-collections, walk each parent document and export its children with a reference back to the parent id. Keep those parent ids: they become your foreign keys.
Step 2: design the Postgres schema
Now map documents to tables. Go collection by collection.
- A flat collection (users with name, email, created date) maps cleanly to a table with one column per field.
- A reference between documents (an order that stores a
userId) becomes a foreign key. - A sub-collection (each user’s addresses) becomes its own table with a
user_idforeign key. - Deeply nested data that always travels together and you never query into (a settings blob) can live in a single
jsonbcolumn. Don’t overuse this. If you filter or sort by a field, give it a real column.
A rough example:
CREATE TABLE users (
id text PRIMARY KEY, -- keep the original document id
email text UNIQUE NOT NULL,
display_name text,
created_at timestamptz NOT NULL DEFAULT now(),
settings jsonb -- rarely-queried blob
);
CREATE TABLE orders (
id text PRIMARY KEY,
user_id text NOT NULL REFERENCES users(id),
total_cents integer NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL
);
Keep the original document ids as your primary keys during the migration. It makes matching records across the two systems far easier while you’re verifying.
Step 3: transform and load
Write a loader that reads your exported JSON, reshapes each record to match the schema, and inserts it. Watch for the traps specific to document databases:
- Timestamps. Document timestamps often export as objects with seconds and nanoseconds. Convert them to real
timestamptzvalues. - Missing fields. Documents in the same collection can have different fields. Postgres columns need a value or a null. Decide a default for each.
- References. A document reference typically exports as a path string. Turn it into the plain id your foreign key expects.
Load parents before children so foreign keys resolve. Wrap each collection’s load in a transaction so a failure rolls back cleanly instead of leaving half the data in.
Step 4: migrate auth
If you use the platform’s built-in authentication, users can’t just be copied, because the platform hashes passwords with its own scheme. You have two workable options:
- Export the password hashes. Most platforms’ CLIs can export users including their hash and hashing parameters. You can verify against those hashes in your new system if you reimplement the platform’s hashing scheme. This is fiddly but keeps everyone’s password working.
- Force a password reset. Import users without passwords and email everyone a reset link on cutover. Simpler and safer, but it’s friction for your users.
Most small apps take option 2. It’s honestly less risky than getting a custom hash-verify wrong.
Step 5: verify, then cut over
Before you switch anything live:
- Count rows per table and compare to the document counts in your old platform.
- Spot-check 20 records by id across both systems.
- Run your app’s real queries against the new Postgres and confirm the results.
For the cutover itself, the safest pattern is a short read-only window: freeze writes to the old platform, run a final delta export of anything changed since your last export, load it, then point your app at Postgres. For a small app that window can be a few minutes at a quiet hour.
Or, we do it for you
A NoSQL-to-Postgres move is exactly the kind of one-time job that goes sideways when you do it live and alone. Our Rescue service handles the export, schema mapping, auth migration, and a verified cutover, so you don’t lose data or a weekend. If you’d rather DIY the database itself, the backend-as-a-service to Postgres guide covers a related path.
FAQ
How long does a NoSQL to Postgres migration take?
For a small app with a handful of collections, a careful person can do it in a few days: a day to design the schema, a day to write and test the loaders, and a short cutover. Bigger apps with sub-collections, auth, and serverless functions take longer because there’s more to reshape and re-test.
Will my app be down during the migration?
It doesn’t have to be down for long. You do the bulk export and load while the app runs normally on the old platform. Only the final cutover needs a short read-only window, often just a few minutes, to sync the last changes and switch over.
Can I keep some data in the NoSQL platform and move the rest?
You can, but running two databases is more work, not less. It’s usually cleaner to move everything in one project. If you must split, move the data that needs SQL queries and relationships first, since that’s where a document database hurts most.
What happens to my serverless functions?
They don’t migrate automatically. Serverless functions are code, so you port them to whatever runs your Postgres app: a Node or Bun service, scheduled jobs, or database triggers. Plan for this. It’s often the most overlooked part of leaving a NoSQL platform.
Get a free production audit
15-point check, scored report back in 48 hours, free either way.
Get the next post by email
One email when we publish something new. No drip sequence, unsubscribe anytime.
Double opt-in: you'll get a confirmation email before you're subscribed. See our privacy policy.