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 feature | Relational replacement | Migration note |
|---|---|---|
| Sharded write scale (cross-region primary writes) | Citus on Postgres, Vitess on MySQL, Oracle Sharding; otherwise vertical scale + read replicas | The hardest feature to replace — see when not to migrate |
| Multi-document transactions | Native ACID across all rows and documents | MongoDB 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 arrays | Rewrite by hand; usually the longest part of the project |
| Change streams | Logical replication / CDC (Debezium and similar) | PostgreSQL logical decoding, SQL Server CDC and change tracking, MySQL binlog |
| Existing MongoDB drivers | Oracle Database API for MongoDB | Oracle 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.
- 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()anddb.collection.getIndexes()inmongoshgive you most of it. - 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/JSONcolumn), and retire (fields nobody reads). The hybrid layout is the goal; full denormalisation into one-column-per-field is almost always the wrong answer. - 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.
- 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.
- Backfill. Snapshot the collections with
mongoexport, convert the Extended JSON wrappers ($oid,$date,$numberDecimal) into target column types, and bulk load —\copyfor Postgres,LOAD DATAfor MySQL,OPENROWSET(BULK …)withOPENJSONfor SQL Server, SQL*Loader or external tables for Oracle, batchedINSERTs inside one transaction for SQLite. The worked example shows the whole path for one collection. - 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.
- 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 type | PostgreSQL | MySQL | SQL Server | Oracle | SQLite |
|---|---|---|---|---|---|
| ObjectId | char(24) or bytea | CHAR(24) or BINARY(12) | char(24) or binary(12) | CHAR(24) or RAW(12) | TEXT |
| Date | timestamptz | DATETIME(3), stored as UTC | datetime2(3) or datetimeoffset(3) | TIMESTAMP(3) WITH TIME ZONE | TEXT (ISO-8601) |
| Decimal128 | numeric | DECIMAL(p,s) | decimal(p,s) | NUMBER | TEXT to keep every digit |
| Int64 (Long) / Int32 | bigint / integer | BIGINT / INT | bigint / int | NUMBER(19) / NUMBER(10) | INTEGER |
| Boolean | boolean | BOOLEAN (TINYINT(1)) | bit | BOOLEAN (23ai+) or NUMBER(1) | INTEGER 0/1 |
| Binary | bytea | VARBINARY / BLOB | varbinary(max) | BLOB | BLOB |
| Embedded document / array | jsonb | JSON | json (SQL Server 2025, Azure SQL) or nvarchar(max) + ISJSON | JSON (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({})inmongoshagainstSELECT COUNT(*) FROM products. A mismatch is usually rows that failed a cast in theINSERT … 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
$groupwith$sum: "$price"againstSUM(price). Decimal rounding and dropped documents show up here. - Date boundaries. Compare the oldest and newest timestamp. Time-zone mistakes in the
$dateconversion shift both by the same offset. - Spot checks. Pull a handful of documents by
_idand 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.
Jam SQL Studio