Published: 2026-05-14 • Updated: 2026-09-26

How to Migrate from MongoDB to PostgreSQL, MySQL, SQL Server, Oracle, or SQLite

Moving off MongoDB is mostly a schema-mapping and cutover exercise. Every engine in this guide stores JSON natively or in a text column with JSON functions, can index JSON paths, and wraps documents and relational rows in the same ACID transaction, so the work is deciding which fields become columns, converting BSON types, and switching traffic without losing writes. The sections below walk through that engine by engine, with the DDL and load commands for each.

What replaces each MongoDB feature in SQL

Before writing any transform, list which MongoDB features your application actually uses and what each one becomes on the relational side. Where a Mongo feature has no clean SQL analogue, the row says so.

MongoDB featureRelational replacementMigration note
Sharded write scale (cross-region primary writes)Citus on Postgres, Vitess on MySQL, Oracle Sharding; otherwise vertical scale + read replicasThe hardest feature to replace — see when not to migrate
Multi-document transactionsNative ACID across all rows and documentsMongoDB added them in 4.0 (replica sets) and 4.2 (sharded clusters); every relational target has them
Aggregation pipeline ($match, $group, $lookup, $unwind)WHERE, GROUP BY, joins, CTEs, window functions; JSON_TABLE / LATERAL to unwind arraysRewrite by hand; usually the longest part of the project
Change streamsLogical replication / CDC (Debezium and similar)PostgreSQL logical decoding, SQL Server CDC and change tracking, MySQL binlog
Existing MongoDB driversOracle Database API for MongoDBOracle 23ai and later only; every other target means swapping the driver for SQL
Document validation ($jsonSchema)Typed columns, NOT NULL, and CHECK constraints; JSON Schema validation on Oracle 23ai+Lifting validated fields into typed columns usually replaces the schema rule

The 7 migration steps (engine-agnostic)

Most migrations follow the same shape, and the order matters: skipping the inventory step is the most common reason a migration over-runs.

  1. Inventory. List every collection. For each, capture document count, document-size distribution (p50, p95, p99, max), write rate, every index that actually serves a query (drop unused ones now, not later), and the BSON-specific types in use. db.collection.stats() and db.collection.getIndexes() in mongosh give you most of it.
  2. Schema mapping. For each collection, split fields into three buckets: relational columns (anything you query, sort, or join on; especially foreign keys to existing relational tables), JSON tail (everything else, kept inside one jsonb / JSON column), and retire (fields nobody reads). The hybrid layout is the goal; full denormalisation into one-column-per-field is almost always the wrong answer.
  3. Map BSON types. Decide the destination type for every BSON type you found in step 1 using the table below. See JSON in Relational Databases Compared for the JSON feature differences between engines.
  4. Set up dual writes or CDC. Stream later changes into the relational target with Debezium's MongoDB connector or a similar CDC tool, or change the application to write to both stores. CDC is less invasive; dual writes catch transform bugs earlier.
  5. Backfill. Snapshot the collections with mongoexport, convert the Extended JSON wrappers ($oid, $date, $numberDecimal) into target column types, and bulk load — \copy for Postgres, LOAD DATA for MySQL, OPENROWSET(BULK …) with OPENJSON for SQL Server, SQL*Loader or external tables for Oracle, batched INSERTs inside one transaction for SQLite. The worked example shows the whole path for one collection.
  6. Cutover. Shadow reads first — run production reads against both stores and diff the results. When the diff stays empty long enough, cut primary reads to SQL. Then cut writes. The CDC stream is your safety net during this window.
  7. Verify and decommission. Run the checks below, stop the application from writing to MongoDB, stop the CDC stream, take one final snapshot, archive it for your retention window, and decommission the cluster.

BSON type mapping per engine

mongoexport writes MongoDB Extended JSON v2 in relaxed mode by default: an ObjectId arrives as {"$oid": "…"}, a date as {"$date": "2026-03-01T09:30:00Z"}, a Decimal128 as {"$numberDecimal": "19.99"}. Pick a destination type for each before you write the transform:

BSON typePostgreSQLMySQLSQL ServerOracleSQLite
ObjectIdchar(24) or byteaCHAR(24) or BINARY(12)char(24) or binary(12)CHAR(24) or RAW(12)TEXT
DatetimestamptzDATETIME(3), stored as UTCdatetime2(3) or datetimeoffset(3)TIMESTAMP(3) WITH TIME ZONETEXT (ISO-8601)
Decimal128numericDECIMAL(p,s)decimal(p,s)NUMBERTEXT to keep every digit
Int64 (Long) / Int32bigint / integerBIGINT / INTbigint / intNUMBER(19) / NUMBER(10)INTEGER
BooleanbooleanBOOLEAN (TINYINT(1))bitBOOLEAN (23ai+) or NUMBER(1)INTEGER 0/1
BinarybyteaVARBINARY / BLOBvarbinary(max)BLOBBLOB
Embedded document / arrayjsonbJSONjson (SQL Server 2025, Azure SQL) or nvarchar(max) + ISJSONJSON (21c+)TEXT or JSONB BLOB

Two limits to check against your inventory: MySQL's DECIMAL tops out at 65 digits and SQL Server's decimal at 38, both enough for Decimal128's 34 significant digits. MySQL's TIMESTAMP only covers 1970–2038, which is why the table uses DATETIME.

Worked example: one collection from mongoexport to PostgreSQL

A products collection, exported with the defaults — one document per line:

mongoexport --uri="$MONGO_URI" --collection=products --out=products.json

# one line of products.json
{"_id":{"$oid":"66f1c2a9e4b0a1b2c3d4e5f6"},"sku":"TEA-042","price":{"$numberDecimal":"19.99"},"createdAt":{"$date":"2026-03-01T09:30:00Z"},"brand":"Jam","tags":["green","loose-leaf"]}

Load every line, untouched, into a one-column staging table. CSV mode with a quote and delimiter byte that never occur in JSON makes each line a single field without backslash processing:

CREATE TABLE products_stage (doc jsonb NOT NULL);

\copy products_stage (doc) FROM 'products.json' WITH (FORMAT csv, QUOTE e'\x01', DELIMITER e'\x02')

Then unwrap the Extended JSON into typed columns and keep the rest of the document as the JSON tail:

CREATE TABLE products (
  id          char(24) PRIMARY KEY,
  sku         text NOT NULL UNIQUE,
  price       numeric(12,2) NOT NULL,
  created_at  timestamptz NOT NULL,
  data        jsonb NOT NULL
);

INSERT INTO products (id, sku, price, created_at, data)
SELECT doc -> '_id' ->> '$oid',
       doc ->> 'sku',
       (doc -> 'price' ->> '$numberDecimal')::numeric,
       (doc -> 'createdAt' ->> '$date')::timestamptz,
       doc - '_id' - 'sku' - 'price' - 'createdAt'
FROM products_stage;

Doing the conversion in SQL rather than in a script has one advantage: rows that fail a cast fail loudly in one statement, and you can query the staging table to find them (WHERE doc -> 'price' ->> '$numberDecimal' IS NULL). The same staging pattern works on the other engines with their own JSON operators — JSON_VALUE on SQL Server and Oracle, ->> on MySQL and SQLite.

Trying the mapping on a sample first. Paste a few lines of mongoexport output into the JSON to SQL converter. It unwraps $oid, $date, $numberLong, and $numberDecimal, infers a type for every top-level key, and writes CREATE TABLE plus INSERT statements for PostgreSQL, MySQL, SQL Server, Oracle, or SQLite — in the browser, up to 5 MiB and 20,000 rows, without uploading anything. Nested objects and arrays land in one JSON column each, which is the JSON tail from step 2.

Loading a whole export from disk. Jam SQL Studio's Data Import reads the same NDJSON file as a stream into a new or existing table on any of the five engines. It keeps the Extended JSON wrappers as JSON rather than unwrapping them, so it fits the staging step above; finish the type conversion with an INSERT … SELECT. The free Personal licence imports up to 100,000 rows per run.

Engine-by-engine playbook

Five targets, five sets of trade-offs. Each playbook below assumes you've done steps 1–3 and shows how to model the destination and bulk-load it.

PostgreSQL

Use a hybrid layout: real columns for fields you join or filter on, a single jsonb column for the document tail. A GIN index over the tail handles ad-hoc containment and path queries; B-tree expression indexes handle the hot paths you query by name.

-- whole-document GIN for containment (@>) and jsonpath (@?, @@) queries
CREATE INDEX products_data_gin ON products USING gin (data jsonb_path_ops);

-- hot-path B-tree on a specific JSON field
CREATE INDEX products_data_brand ON products ((data->>'brand'));

Pick the GIN operator class to match your queries: jsonb_path_ops is smaller and faster for @>, @?, and @@, but only the default jsonb_ops class indexes the key-existence operators ?, ?|, and ?&. Tag-array lookups (Mongo's multikey index) translate to containment: data @> '{"tags": ["green"]}'. PostgreSQL 17 added the SQL/JSON JSON_TABLE, JSON_VALUE, JSON_QUERY, and JSON_EXISTS functions, which help when rewriting $unwind stages. See the PostgreSQL JSON & JSONB guide for operators and indexing patterns.

MySQL

MySQL has had a native binary JSON type since 5.7.8, so it's a pragmatic target when the team already runs MySQL. The indexing model differs from Postgres: there is no GIN equivalent, so you index hot paths via generated columns plus B-tree, and array-of-values fields via multi-valued indexes (8.0.17+).

CREATE TABLE products (
  id          char(24) PRIMARY KEY,
  sku         varchar(64) NOT NULL UNIQUE,
  price       decimal(12,2) NOT NULL,
  created_at  datetime(3) NOT NULL,
  data        json NOT NULL,
  brand       varchar(128) GENERATED ALWAYS AS (data->>'$.brand') STORED,
  KEY idx_brand (brand),
  -- multi-valued index for array-of-tags
  KEY idx_tags ((CAST(data->'$.tags' AS CHAR(64) ARRAY)))
);

Load the export into a one-column staging table with LOAD DATA LOCAL INFILE 'products.json' INTO TABLE products_stage FIELDS ESCAPED BY '' (doc); — the empty ESCAPED BY keeps JSON's backslash escapes intact — then INSERT … SELECT with paths such as doc->>'$._id."$oid"' (quote keys that start with $). See the MySQL JSON Columns guide for the operator vocabulary and generated-column patterns.

SQL Server

SQL Server's JSON story depends on the version. The native json type is generally available in SQL Server 2025, Azure SQL Database, and Azure SQL Managed Instance (with the SQL Server 2025 or Always-up-to-date update policy). On SQL Server 2016–2022, JSON lives in nvarchar(max) with an ISJSON check constraint. The example below uses the 2022-compatible form; on 2025 you can declare data json instead.

-- SQL Server 2016-2022 (on 2025 / Azure SQL: data json NOT NULL)
CREATE TABLE products (
  id          char(24) PRIMARY KEY,
  sku         nvarchar(64) NOT NULL UNIQUE,
  price       decimal(12,2) NOT NULL,
  created_at  datetimeoffset(3) NOT NULL,
  data        nvarchar(max) NOT NULL CHECK (ISJSON(data) = 1),
  brand       AS JSON_VALUE(data, '$.brand') PERSISTED,
  INDEX ix_brand (brand)
);

-- Load a JSON array export (mongoexport --jsonArray) and shred it with OPENJSON
INSERT INTO products (id, sku, price, created_at, data)
SELECT j.id, j.sku, CAST(j.price AS decimal(12,2)),
       CAST(j.created_at AS datetimeoffset(3)), j.data
FROM OPENROWSET(BULK 'C:\stage\products.json', SINGLE_CLOB) AS raw
CROSS APPLY OPENJSON(raw.BulkColumn) WITH (
  id          char(24)       '$._id."$oid"',
  sku         nvarchar(64)   '$.sku',
  price       nvarchar(40)   '$.price."$numberDecimal"',
  created_at  nvarchar(40)   '$.createdAt."$date"',
  data        nvarchar(max)  '$' AS JSON
) j;

OPENJSON over a SINGLE_CLOB expects one JSON document, so export with --jsonArray for this path. Persisted computed columns on top of JSON_VALUE are the indexing pattern that works on every supported version. See the SQL Server JSON Support guide for the function reference and version matrix.

Oracle

Oracle is the one target where the application can keep its MongoDB drivers. The Oracle Database API for MongoDB, available with Oracle Database 23ai and later (including Oracle AI Database 26ai), receives commands over the MongoDB wire protocol and translates them into SQL; it is built into Autonomous Database and runs as part of Oracle REST Data Services elsewhere. Documents land in the native JSON type (binary OSON, introduced in 21c), so the SQL side gets JSON_TABLE, JSON_VALUE, and JSON_EXISTS, and indexing via JSON search indexes, function-based indexes, and multivalue indexes for arrays.

CREATE TABLE products (
  id          char(24) PRIMARY KEY,
  sku         varchar2(64) NOT NULL UNIQUE,
  price       number(12,2) NOT NULL,
  created_at  timestamp(3) with time zone NOT NULL,
  data        json NOT NULL
);

-- JSON search index for ad-hoc path queries (closest Oracle has to GIN)
CREATE SEARCH INDEX products_data_idx ON products (data) FOR JSON;

-- Function-based index for a hot path
CREATE INDEX products_brand_idx
  ON products (JSON_VALUE(data, '$.brand' RETURNING VARCHAR2(128)));

If the application talks MongoDB today, pointing its connection string at the Database API for MongoDB endpoint lets you move the data first and do the relational schema work later. See the Oracle JSON Support guide for OSON, JSON Relational Duality, and the API surface.

SQLite

SQLite fits embedded and edge workloads — a sync engine that ships a copy of the data to every client, a mobile app's local store, a single-tenant app where each customer gets their own file. It is the wrong target for anything write-heavy in a multi-user server context, because there is only ever one writer at a time. Within that constraint, the JSON functions (built in by default since 3.38.0) and the JSONB binary format (3.45.0) give you json_extract, -> and ->>, generated columns for indexed paths, and full ACID.

CREATE TABLE products (
  id          text PRIMARY KEY,
  sku         text NOT NULL UNIQUE,
  price       text NOT NULL,     -- Decimal128 kept as text
  created_at  text NOT NULL,     -- ISO-8601
  data        blob NOT NULL,     -- JSONB
  brand       text GENERATED ALWAYS AS (data->>'$.brand') STORED
);

CREATE INDEX products_brand_idx ON products (brand);

Bulk-load by wrapping one BEGIN / COMMIT around batched INSERTs — SQLite's per-transaction commit cost is what makes naive row-by-row imports slow. See the SQLite JSON Support guide for the JSONB format and operator edge cases.

Verify the migration

Before you cut writes over, and again before you decommission, compare the two stores per collection:

  • Counts. db.products.countDocuments({}) in mongosh against SELECT COUNT(*) FROM products. A mismatch is usually rows that failed a cast in the INSERT … SELECT, still sitting in the staging table.
  • Aggregates. Pick a numeric field and compare the sum, minimum, and maximum on both sides — for example an $group with $sum: "$price" against SUM(price). Decimal rounding and dropped documents show up here.
  • Date boundaries. Compare the oldest and newest timestamp. Time-zone mistakes in the $date conversion shift both by the same offset.
  • Spot checks. Pull a handful of documents by _id and compare every field with the row and its JSON tail.
  • Shadow reads. The production queries you rewrote in step 6 are the real test; keep diffing them until the rewritten SQL matches for long enough to trust it.

When NOT to migrate

Don't migrate when MongoDB is genuinely the right tool. The three patterns where this is most defensibly true: sharded multi-region writes at high sustained rates, where MongoDB's sharding is built in and the relational equivalents (Citus, Vitess, Oracle Sharding) are real but operationally heavier; workloads that lean hard on the aggregation pipeline, where teams have built mental models and tooling around $lookup, $facet, and $bucket and the rewrites would be a project of their own; and documents that approach the 16 MB BSON limit, where the migration will fight large-value storage limits in the relational target.

The fourth, often underweighted, factor is team shape. A team that has run MongoDB in production for years, with on-call runbooks that assume it, pays a real operational cost for a relational migration even when the database itself can handle the workload. Quantify it before committing. For the broader choice of target, see MongoDB alternatives in 2026.

Quick Answers

Short answers to the questions that come up most during a MongoDB-to-SQL migration.

Q: How do I load mongoexport output into PostgreSQL?

A: mongoexport writes one document per line by default. Load each line into a staging table with a single jsonb column using \copy in CSV mode with a quote and delimiter character that never appear in the data, then INSERT … SELECT into the typed target table, unwrapping $oid, $date and $numberDecimal with the ->> operator and a cast.

Q: How do I handle BSON-specific types when migrating to SQL?

A: BSON has types that JSON does not: ObjectId, Decimal128, native Date, Binary, and Long. mongoexport writes them as Extended JSON wrappers ({"$oid": …}, {"$date": …}, {"$numberDecimal": …}). Map ObjectId to a 24-character text column (or BINARY(12) / bytea), Decimal128 to NUMERIC / DECIMAL, Date to a timestamp type, Binary to BYTEA / VARBINARY / BLOB, and Long to BIGINT, and unwrap the wrappers in a transform step before or after the bulk load.

Q: Can I keep using MongoDB drivers after migrating to Oracle?

A: Yes. The Oracle Database API for MongoDB, available with Oracle Database 23ai and later, accepts the MongoDB wire protocol and translates commands into SQL, so applications using MongoDB drivers can connect to Oracle and read or write JSON collections. It runs inside Autonomous Database, and as part of Oracle REST Data Services for other deployments.

Q: How long does a MongoDB-to-Postgres migration take?

A: As a rough planning range for a small application (a few collections, under 100 GB, no aggregation-heavy reporting): two to four weeks of engineering time — about a week for schema mapping and the transform, a week for CDC or dual writes plus the backfill, and one to two weeks for shadow reads, cutover, and decommissioning. The backfill itself is rarely the bottleneck; query rewrites and aggregation-pipeline replacements are.

Q: Do I need to denormalise documents into relational columns during migration?

A: Partially, and only for fields you actually query or join on. The pragmatic pattern is a hybrid schema: lift fields you filter, sort, or join on out of the document into typed relational columns (with NOT NULL and CHECK constraints where useful) and leave everything else inside a single JSON / jsonb column. This avoids the all-or-nothing trap of fully denormalising a document into dozens of columns while still letting the query planner use B-tree indexes on hot fields.

Q: Can I do a zero-downtime MongoDB-to-SQL migration?

A: Yes, with change data capture. Snapshot the existing collections into the relational target, stream later changes with a CDC tool such as Debezium's MongoDB connector, run shadow reads until the new database matches the old on every test query, then cut reads over and finally cut writes. The tooling moves the data; the engineering work is the schema mapping and the reconciliation.

Browsing both worlds during migration

During the migration window the same data lives in two stores with different tooling. On the SQL side, Jam SQL Studio treats the JSON tail as a first-class column on all five engines in this guide: a JSON cell expands into a collapsible tree, the json filter operator in Table Explorer takes a JSONPath with sub-operators such as has property, property =, and property contains and emits the engine's own syntax (#>> on PostgreSQL, JSON_VALUE on SQL Server and Oracle, JSON_EXTRACT on MySQL, json_extract on SQLite), and a peek popover lists the JSON paths seen in the column, sorted by how often they occur. That last one is the quickest way to check that the tail column holds the shape you expected after the INSERT … SELECT. See JSON columns.

Check the Migrated Data in One Client

Jam SQL Studio imports NDJSON, renders JSON as a tree, and filters by JSON path on PostgreSQL, MySQL, SQL Server, Oracle, and SQLite. Free for personal use.

Checked against the MongoDB, PostgreSQL, MySQL, Microsoft Learn, Oracle, and SQLite documentation on 2026-09-26.

Related