Published: 2026-05-14

Migrate From MongoDB to Postgres, MySQL, SQL Server, Oracle, or SQLite (2026)

Migrating off MongoDB used to mean accepting a feature regression — losing path queries, document validation, or operational tooling that had no relational equivalent. That stopped being true years ago. By 2026 every major relational engine stores documents in a native or de-facto binary JSON format, indexes JSON paths, and supports ACID transactions that span document and relational tables. The work is now squarely a schema-mapping and operational-cutover exercise, and this guide walks through both, engine by engine.

Why teams migrate off MongoDB in 2026

The push factors are mostly the same ones teams have raised since 2018, but each is sharper now. MongoDB's relicensing to the Server Side Public License pulled the rug from under managed-service providers and self-hosted users alike, and the cottage industry of compatible-but-not-MongoDB forks (FerretDB, Amazon DocumentDB, Azure Cosmos DB for MongoDB) is itself an admission that a lot of teams want the API without the licence. For organisations with procurement teams that scrutinise copyleft and source-available terms, the SSPL alone is enough to start the conversation.

The pull factors are what changed the calculation. PostgreSQL 17 (2024) shipped the standards-track JSON_TABLE, JSON_VALUE, JSON_QUERY, and JSON_EXISTS functions, closing the last meaningful gap with Mongo's query expressiveness on top of a decade of jsonb and GIN. Azure SQL Database now ships a native json type in general availability and SQL Server 2025 brings it on-prem in preview. Oracle 23ai (consolidated into Oracle AI Database 26ai for 2026) added the Database API for MongoDB — the only relational engine that accepts MongoDB's wire protocol natively — on top of its existing native JSON type and JSON Search indexes.

Operationally, the case for a single database engine has only gotten stronger. Atlas pricing scales with cluster count, region, and storage, and a typical Mongo cluster ends up provisioned alongside a relational primary that already holds the user, billing, and audit tables. Collapsing the two means one backup story, one HA story, one set of credentials, one place to run analytics, and arbitrary JOINs between documents and rows — the thing relational engines have always done well. ACID across documents and relational rows in a single transaction matters more than feature-list parity once you have to reason about correctness during a failure.

None of this is a case against MongoDB. The point is that the default has shifted: in 2026, "we'll put it in MongoDB" is a decision that needs justifying against a relational engine that can almost certainly do the same job with less operational surface.

Compatibility decision matrix

The matrix below maps Mongo capabilities to their closest relational equivalents and the engine that handles each best. Where a Mongo feature has no clean SQL analogue, the row says so.

MongoDB capabilityRelational equivalentBest-fit engine
Sharded write scale (cross-region primary writes)Citus on Postgres, Vitess on MySQL, Oracle Sharding option; otherwise vertical scale + read replicasOracle (Sharding) or Postgres + Citus — or keep Mongo if >10k/s writes/region is non-negotiable
Multi-document transactionsNative ACID across all rows / documentsAll five — relational engines have had this since they existed; Mongo added it in 4.0
Aggregation pipeline ($match, $group, $lookup, $unwind)CTEs + window functions + LATERAL / JSON_TABLE for unwinding arraysPostgreSQL (most expressive; JSON_TABLE in 17) or Oracle (12.2+)
Change streamsLogical replication / CDC via Debezium / Striim / ConfluentPostgreSQL (logical decoding) or SQL Server (CDC + change tracking)
Wire-compatibility with existing Mongo driversNative protocol support on the database sideOracle — the only relational engine that ships this (Database API for MongoDB on 23ai / 26ai)
Document validation (JSON Schema)Built-in JSON type validation + CHECK constraints; full JSON Schema on Oracle 23aiOracle (real JSON Schema) or PostgreSQL (CHECK + jsonb_path_exists)

The 7 migration steps (engine-agnostic)

Every successful migration we've seen follows roughly the same shape. The order matters: skipping the inventory step is the single 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() are your friends. Aim to fit on one page.
  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. Pick the target engine. Use the matrix above. If you're not sure, the safe defaults are Postgres for open-source, Oracle when wire-compatibility matters, MySQL when the team already runs MySQL. See JSON in Relational Databases Compared for the deeper feature comparison.
  4. Set up dual writes or CDC. Stream the Mongo oplog into the relational target with Debezium, Striim, or Confluent's MongoDB source connector. Alternatively, 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 --jsonFormat=canonical, transform the BSON wrapper objects ($oid, $date, $numberDecimal) into target column types in a streaming step, and bulk load — \copy for Postgres, LOAD DATA INFILE for MySQL, BULK INSERT / OPENJSON for SQL Server, SQL*Loader or external tables for Oracle, multi-row INSERT inside a transaction for SQLite.
  6. Cutover. Shadow reads first — run every production read against both stores and diff results in a low-traffic environment. When the diff is empty for long enough, cut primary reads to SQL. Then cut writes. The CDC stream is your safety net during this window.
  7. Decommission. Stop the application from writing to Mongo, stop the CDC stream, take one final Atlas snapshot, archive it somewhere cheap, and decommission the cluster. Keep the snapshot for the duration of your compliance retention window.

Engine-by-engine playbook

Five targets, five sets of trade-offs. Each playbook below assumes you've already done steps 1–3 above and are choosing how to model the destination.

PostgreSQL

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

CREATE TABLE products (
  id          uuid PRIMARY KEY,
  sku         text NOT NULL UNIQUE,
  price_cents integer NOT NULL,
  status      text NOT NULL,
  created_at  timestamptz NOT NULL,
  data        jsonb NOT NULL
);

-- whole-document GIN for ad-hoc 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'));

Bulk-load with a streaming transform from mongoexport output through jq (or a small Python script) into TSV, then \copy. For the JSON column, build the document explicitly with jsonb_build_object in a staging table when you need to drop or rename fields during the migration. Multi-valued queries against tag-array fields work directly on jsonb with the ? and ?| operators — no need for the generated-column dance that MySQL requires.

See the full PostgreSQL JSON & JSONB guide for operators, the SQL/JSON path language, and indexing patterns.

MySQL

MySQL has had a native binary JSON type since 5.7 (2015), so it's a perfectly pragmatic target when the team already runs MySQL. The indexing model is different from Postgres: 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          binary(16) PRIMARY KEY,
  sku         varchar(64) NOT NULL UNIQUE,
  price_cents int NOT NULL,
  status      varchar(32) NOT NULL,
  created_at  datetime(6) 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)))
);

Bulk-load with LOAD DATA INFILE from a JSON-Lines file (one document per line). For very large collections, split the export into chunked files and parallelise loads with --threads. See the MySQL JSON Columns guide for the operator vocabulary and generated-column patterns.

SQL Server

SQL Server's JSON story is split by edition and version. Azure SQL Database and Azure SQL Managed Instance have a native json type GA. SQL Server 2025 brings it on-prem in preview. For SQL Server 2016–2022 (everything currently in mainstream support except 2025), JSON lives in NVARCHAR(MAX) with an ISJSON CHECK constraint. Do not assume native JSON on SQL Server 2022.

-- SQL Server 2022 (and earlier supported releases)
CREATE TABLE products (
  id          uniqueidentifier PRIMARY KEY,
  sku         nvarchar(64) NOT NULL UNIQUE,
  price_cents int NOT NULL,
  status      nvarchar(32) NOT NULL,
  created_at  datetime2(3) NOT NULL,
  data        nvarchar(max) NOT NULL CHECK (ISJSON(data) = 1),
  brand       AS JSON_VALUE(data, '$.brand') PERSISTED,
  INDEX ix_brand (brand)
);

-- Bulk-load shape: stage as JSON in a single column, shred with OPENJSON
INSERT INTO products (id, sku, price_cents, status, created_at, data)
SELECT
  CAST(j.id AS uniqueidentifier), j.sku, j.price_cents, j.status,
  CAST(j.created_at AS datetime2(3)), j.data
FROM OPENROWSET(BULK 'C:\stage\products.jsonl', SINGLE_CLOB) AS raw
CROSS APPLY OPENJSON(raw.BulkColumn) WITH (
  id          nvarchar(36)   '$.id',
  sku         nvarchar(64)   '$.sku',
  price_cents int            '$.price_cents',
  status      nvarchar(32)   '$.status',
  created_at  nvarchar(40)   '$.created_at',
  data        nvarchar(max)  '$' AS JSON
) j;

Persisted computed columns on top of JSON_VALUE are the universal indexing pattern across every supported version. See the SQL Server JSON Support guide for the function reference and version matrix.

Oracle

Oracle is the lowest-friction migration target if your application is heavily driver-bound to MongoDB, because Oracle Database 23ai (now part of Oracle AI Database 26ai) ships the Oracle Database API for MongoDB — an endpoint that speaks the MongoDB wire protocol. Existing application code using the official MongoDB drivers connects to Oracle and reads or writes JSON collections without code changes. Underneath, documents land in the native JSON type (OSON binary, shipped in 21c), so you also get SQL-side access via JSON_TABLE, JSON_VALUE, and JSON_EXISTS and indexing via JSON Search indexes, function-based indexes, and multi-value indexes for arrays.

CREATE TABLE products (
  id          raw(16) PRIMARY KEY,
  sku         varchar2(64) NOT NULL UNIQUE,
  price_cents number(12) NOT NULL,
  status      varchar2(32) NOT NULL,
  created_at  timestamp 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 Mongo today, point its connection string at the Database API for MongoDB endpoint and start writing — the relational schema discipline can follow later. See the Oracle JSON Support guide for OSON internals, JSON Relational Duality, and the API surface.

SQLite

SQLite is the right answer for 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 SaaS where each customer gets their own file. It is the wrong answer for anything write-heavy in a multi-user server context, because there's only ever one writer at a time. With that constraint accepted, json1 (built in and on by default since 3.38) plus the JSONB binary BLOB format added in 3.45 give you everything you need: json_extract, -> and ->>, generated columns for indexed paths, and full ACID inside the one-writer envelope.

CREATE TABLE products (
  id          text PRIMARY KEY,
  sku         text NOT NULL UNIQUE,
  price_cents integer NOT NULL,
  status      text NOT NULL,
  created_at  text NOT NULL,
  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 a single big BEGIN / COMMIT around chunked INSERTs — SQLite's per-row commit cost is what kills naive imports. See the SQLite JSON Support guide for the JSONB format and edge-case operator behaviour.

When NOT to migrate

The honest answer is: don't migrate when MongoDB is genuinely the right tool. The three patterns where this is most defensibly true: sharded multi-region writes above roughly 10k writes per second per region, where Mongo's sharding has been first-class since the beginning 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 equivalent CTE-plus-JSON_TABLE rewrites would be a months-long subproject in their own right; and document sizes that routinely approach the 16 MB BSON limit, where the migration is going to fight with TOAST / LOB / max-packet limits in the relational target.

The fourth, often underweighted, factor is team shape. A team that has been running Mongo in production for years, has on-call runbooks that assume Mongo, and is hiring from a pool that lists "MongoDB" on their CV will pay a real operational cost for a relational migration even when the database itself can handle the workload. Migrating off a database you understand to one you don't is its own risk. Quantify it before committing.

Browsing both worlds during migration

One of the more annoying parts of a migration window is that the same data now lives in two stores with very different tooling. Jam SQL Studio narrows that gap on the SQL side: JSON columns on every target engine in this guide — jsonb on Postgres, JSON on MySQL, native json or NVARCHAR(MAX) on SQL Server, native JSON or CLOB-with-IS JSON on Oracle, TEXT or JSONB on SQLite — render as collapsible trees in the results grid, the filter chip exposes path-aware sub-operators (has property, property =, property contains) that compile to the engine-correct operator under the hood, and a Peek popover scans the loaded rows to show every JSON path with its prevalence. Roughly the schema-discovery experience MongoDB Compass made familiar, applied uniformly to all five relational engines.

Make Mongo-Shaped Data Feel Familiar in SQL

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

Last verified against MongoDB docs and the 5 engine JSON references on 2026-05-14.

Related