Skip to content
adapters.io

Convert MongoDB to PostgreSQL: documents to tables and JSONB, with the checks that prove each mapping is safe

10 min read Databases The Adapters team

Field mapping auto-plugged · tap a port to rewire

5 sample records ready

To convert MongoDB to PostgreSQL safely, put the fields you query into typed columns, keep the rest of each document in a JSONB column, give arrays of subdocuments their own child tables, and run a full field inventory against MongoDB before the load. That last step is the one most migrations skip. The best-known tools infer your schema from a sample of 1,000 to 10,000 documents, and their own documentation says what happens to fields the sample missed: they are not replicated, or the mismatched values are written as NULL.

Key takeaways

  • Sampling is the main risk. AWS DMS table mode scans 1,000 documents by default and does not replicate fields missing from the target. Airbyte samples 10,000 and writes structural mismatches as NULL.
  • Tables and JSONB, not tables or JSONB. Typed columns for what you filter and join on, the whole document beside them for everything else.
  • Export in canonical format. mongoexport defaults to relaxed Extended JSON, which MongoDB says "can lose type information".
  • Nine mongosh queries prove the mapping. Run them against MongoDB before the load, not against PostgreSQL after it.

Should I convert MongoDB documents to tables or JSONB?

Use both in the same table. The columns your application and reports filter, join, sort and sum on become typed PostgreSQL columns, with constraints where you can. The full document goes into a jsonb column next to them, so nothing is lost and a field you did not model on day one is still queryable on day ninety. Arrays of subdocuments, such as line items on an order, get their own child table keyed on the parent id plus the position in the array.

The two pure strategies each fail in a predictable way. All JSONB is quick to build and it is what several tools produce by default: AWS DMS puts each document into a single _doc column in its default document mode, and Fivetran's default packed mode writes the whole document as JSON. You have moved the data and none of the queries. All tables, on the other hand, breaks the first time a document changes shape, which in a MongoDB application happens with ordinary releases. Keep in mind that jsonb is not a byte-for-byte copy either. PostgreSQL documents that it "does not preserve the order of object keys, and does not keep duplicate object keys", keeping only the last value.

How do MongoDB data types map to PostgreSQL?

Most BSON types have one correct PostgreSQL target, and the trouble comes from defaults that pick a different one. Here is the mapping we use, with the note that matters for each.

BSON type PostgreSQL target What to watch
ObjectId text (24 hex characters) or bytea (12 bytes) Strip the $oid wrapper. Keep one representation across every table that joins on it.
Date timestamptz BSON dates are UTC milliseconds. A column without time zone invites a four or five hour error in US reports.
Int32 integer Safe, as long as no document stores the same field as a long.
Int64 (long) bigint Export with canonical Extended JSON, or a parser may read it as a double.
Double double precision Do not promote to numeric and assume the values became exact.
Decimal128 numeric The right home for money. Never let a default route it through double precision.
String text Strip NUL characters first; PostgreSQL text cannot hold them.
Boolean boolean Watch for the same field stored as "true" strings in older documents.
Embedded document Typed columns for used paths, jsonb for the rest Flattened names must stay under 63 bytes.
Array of values A PostgreSQL array, or jsonb Arrays of mixed types go to jsonb.
Array of documents A child table keyed on parent id plus position The single decision that most affects report accuracy.

Names need the same care as values. PostgreSQL keeps no more than 63 bytes of an identifier and truncates anything longer, and flattened paths such as billing_address_customer_primary_contact_phone_extension get there faster than you would think. Unquoted names are folded to lower case, so a MongoDB field called createdAt becomes createdat unless every query quotes it forever. Rename to snake_case in the mapping and the problem never reaches your SQL. The full tool comparison, with each vendor's default quoted from its own documentation, is on MongoDB to PostgreSQL migration tools.

What should I check in MongoDB before converting to PostgreSQL?

Nine things, and each one is a single mongosh command against the source. This is the step that replaces the tools' sampling with facts. The first query reads every document in the collection, so run it against a secondary or off hours on a large collection. It still finishes in minutes on most production datasets, which is a small price for knowing your real schema.

What it finds The mongosh check What it prevents
Every field, with its types and counts db.orders.aggregate([{ $project: { f: { $objectToArray: "$$ROOT" } } }, { $unwind: "$f" }, { $group: { _id: { k: "$f.k", t: { $type: "$f.v" } }, n: { $sum: 1 } } }]) Fields a 1,000 or 10,000 document sample would miss
Fields holding more than one type Append to the query above: { $group: { _id: "$_id.k", types: { $addToSet: "$_id.t" } } }, { $match: { "types.1": { $exists: true } } } Values a typed column would turn into NULL
Longs too big for a double db.orders.countDocuments({ qty: { $type: "long", $gt: NumberLong("9007199254740992") } }) Silent precision loss in a JSON parser
Money stored as Decimal128 db.orders.countDocuments({ total: { $type: "decimal" } }) Currency a default would round through double
Dates stored as strings db.orders.countDocuments({ createdAt: { $type: "string" } }) A timestamptz column that rejects or nulls rows
Strings containing NUL db.customers.countDocuments({ name: /\x00/ }) Rows PostgreSQL refuses to load
The largest array db.orders.aggregate([{ $group: { _id: null, max: { $max: { $size: { $ifNull: ["$items", []] } } } } }]) How many child rows one order becomes
Names that collide in lower case Replace the $group in the first query with: { $group: { _id: { $toLower: "$f.k" }, names: { $addToSet: "$f.k" } } }, { $match: { "names.1": { $exists: true } } } createdAt and createdat landing on one column
Missing field against explicit null Compare db.orders.countDocuments({ total: { $exists: false } }) with db.orders.countDocuments({ total: { $type: "null" } }) Two meanings collapsed into one NULL

The first query is the important one. It unrolls each document into key and value pairs and counts every combination of field name and BSON type across the whole collection, not across a sample. Its output is the column list for your PostgreSQL table, along with the evidence you need to choose each column's type. Run it once per collection and once per embedded document you plan to flatten, swapping "$$ROOT" for the sub-document path such as "$customer". While you are there, add a $strLenBytes filter on the field name to catch anything that will pass 63 bytes once it is prefixed.

One more check belongs before all nine: db.hello().setName. If it returns nothing, you are on a standalone server. Change streams are available only on replica sets and sharded clusters, so no CDC tool can follow changes until you convert the server into a single-node replica set. That is a short job, and a much better one to find in week one than during the cutover.

How do I export MongoDB data for PostgreSQL?

For a one-time copy, run mongoexport --jsonFormat=canonical and load the output into a staging table with a single jsonb column, then build the typed tables with INSERT ... SELECT in SQL. The canonical flag is not optional. mongoexport defaults to relaxed Extended JSON, and MongoDB describes relaxed mode as emphasizing "readability and interoperability at the expense of type preservation". In relaxed output a 64-bit integer is a plain number and a date is an ISO string, so your SQL sees a number and a string where it needed a bigint and a timestamptz.

This route is right for small collections and proofs of concept. It is a point-in-time copy with no change tracking, so the data is stale when the load finishes, and a cutover planned on it needs a write freeze for as long as the whole export and load take.

What is the best tool to convert MongoDB to PostgreSQL?

The one whose default you have read. AWS DMS suits a one-time move inside AWS, as long as you pick table mode deliberately and raise the sample size, because by default it scans 1,000 documents and its documentation states that fields that do not exist in the target "aren't replicated". Airbyte suits teams who want open source, with the caveat that its schema-enforced default writes structural mismatches as NULL. Its schemaless mode avoids that by landing only _id and data, which returns the modeling to you. Fivetran suits teams who want no pipeline to run, though its unpacked mode goes one layer deep, so nested objects stay JSON.

If the actual goal is to leave MongoDB without rewriting the application, none of these is the answer. FerretDB speaks the MongoDB wire protocol and stores the data in PostgreSQL through the DocumentDB extension, so your drivers keep working. You get PostgreSQL operations, and you do not get relational tables, which for some teams is exactly the right trade.

We built Adapters for the case in between: typed PostgreSQL tables from declared document paths, arrays sent to child tables, and an incremental sync afterwards, on a flat monthly price rather than a row meter. Nothing lands that you did not declare, which is the opposite of sampling, and the field inventory above is exactly the list you declare from. The same flat-price pattern for keeping PostgreSQL fed from other sources is on Postgres ETL tools.

What happens to MongoDB queries after the conversion?

They have to be rewritten, and no data tool does it for you. Aggregation pipelines become SQL with joins, GROUP BY and window functions, and $lookup stages usually turn into plain joins against the child tables you created for arrays. Budget this as its own workstream. On many migrations it is larger than the data movement, and it is the part that decides whether the application team signs off on the cutover date.

It also changes who can answer questions. Reports that needed an engineer who could write an aggregation pipeline can now be written in SQL by anyone on the analytics side, and teams that want to go further can let business users turn plain-English questions into SQL against the new tables. That payoff is only real if the schema is clean, which is one more reason to get the mapping right before the load.

How do I keep MongoDB and PostgreSQL in sync until cutover?

Run a full load, then follow the MongoDB change stream and apply every insert, update and delete to PostgreSQL until the switch. That keeps the cutover to minutes. Two checks make it trustworthy. Reconcile document counts against row counts per collection on a schedule, and compare, for each field, the number of documents that hold it with the number of non-NULL values in its column. The second check is the only one that catches a sampled schema or a NULL-on-mismatch default, because neither produces an error. Change streams resume from a position in the oplog, so an outage longer than your oplog window leaves a gap only reconciliation will reveal.

For the wider set of capture options and what each source needs switched on, see change data capture tools. If MongoDB is one of several systems moving at once, the cost side of the program is in what a data migration really costs, and the relational version of this type-by-type work is in converting MySQL data types to PostgreSQL.

MongoDB documents into PostgreSQL tables you named

Declare the paths that matter, send arrays to child tables, keep the full document in JSONB, and sync incrementally until cutover. Flat $49 a month, not metered by rows.

The live demo needs no card, and Starter is $49 a month.

Get started