Blog · Data modeling Personal story

Why I prefer MongoDB over SQL Server

Fifteen years of SQL Server and a stack of Microsoft certificates. I wasn't looking for anything else. Then a few things happened that SQL Server had no good answer for, and today I wouldn't go back. This is that story, told on one ordinary invoice, with the answers to the fears I had myself: no joins, duplicated data, no schema.

Short answer

If you know SQL Server, you already know most of MongoDB: indexes, query plans, replicas, backups. What changes is one habit. You store an invoice as an invoice, not spread over five tables. And the fear that “without joins everything gets duplicated” falls apart the moment you look at a real invoice.

How it clicked

On paper: exams in SQL Server, ASP.NET and C# that added up to an MCPD, and an MCT on top, so I taught it to others too. Then a few moments changed my mind:

  1. A migration script that ran for 12 hours

    A new column, a data conversion, rebuilt indexes. Twelve hours of a maintenance window while customers waited. In MongoDB there is no such script to wait for.

  2. The license price

    SQL Server is paid per core. More customers, more cores, a bigger bill. And the things we needed (more replicas, online index rebuilds) were Enterprise only.

  3. Customers bigger than any server

    Some customers grew so big there was no hardware left to buy. Split them over more servers? Not with foreign keys tying every table to every other. MongoDB splits a collection over servers (sharding) as a built-in feature.

  4. Columns that hold more than a number

    At Tabidoo, users build their own apps, and a “column” can hold an address, a chat, a list of files. In SQL Server that's either a dozen extra tables or JSON in a text column. And indexing inside that JSON meant a computed column for every path. In MongoDB it's just a field, and it gets an index like any other.

What MongoDB gave me instead

No fights with locks No reports frozen behind a long update, no deadlock graphs at 2 am, no NOLOCK “fixes” that quietly return wrong numbers. Reads don't wait for writes, and an invoice with all its items is saved in one go, no transaction needed.
More servers, for free SQL Server Standard gives you two replicas for one database, and you can't even read from the second one. More is Enterprise. A MongoDB replica set costs nothing and takes one command.
New versions without downtime Upgrade one server at a time while the others keep serving. The app keeps running: the driver finds the new primary and retries on its own. I upgrade in the daytime now.
Joins, when you miss them $lookup is there, so the first steps feel familiar. After a while you'll need it far less than you think.
Tuning you already know Indexes (also on lists, more on that below), query plans with explain(), slow query logs. Your SQL Server instincts still work.
Compressed by default 100,000 invoices: 78 MB of data, 22 MB on disk. Nothing to turn on, nothing to rebuild.

One invoice, two databases

An invoice: a customer with a billing address, a few items, a total. In SQL Server the textbook model looks like this:

SQL Server 5 tables · 5 foreign keys · 4 joins to print it
MongoDB
{
  "number": "2025-000042",
  "issuedAt": "2025-03-14",
  "status": "paid",
  "customer": {
    "_id": "6ac95b…",
    "name": "Acme s.r.o.",
    "address": {
      "street": "King Street 42",
      "city": "Brno",
      "zip": "60200"
    }
  },
  "items": [
    { "sku": "SKU-10133",
      "name": "Laptop stand",
      "qty": 2, "unitPrice": 426 },
    { "sku": "SKU-10164",
      "name": "Office chair",
      "qty": 3, "unitPrice": 673 }
  ],
  "total": 2871
}
1 collection · 1 document · 1 read

Now print it. In SQL:

SELECT i.Number, c.Name, a.City, p.Name AS Item, it.Qty, it.UnitPrice
FROM Invoices i
JOIN Customers c         ON c.Id = i.CustomerId
JOIN CustomerAddresses a ON a.Id = i.BillingAddressId
JOIN InvoiceItems it     ON it.InvoiceId = i.Id
JOIN Products p          ON p.Id = it.ProductId
WHERE i.Number = '2025-000042';

Two items, two rows, the header copied into each. Then your code glues it back into one invoice.

Funny, isn't it? The join just duplicated the data, every time you read. In MongoDB:

db.invoices.findOne({ number: "2025-000042" })

The invoice comes back whole, in the shape your code wants. And of course indexes reach inside the document, so finding invoices by customer name is just as fast:

db.invoices.createIndex({ "customer.name": 1 })
db.invoices.find({ "customer.name": "Acme s.r.o." })

The customer has more addresses

Billing, shipping, the warehouse in Ostrava. In SQL that's why CustomerAddresses is a table of its own, with a foreign key and a Type column. In MongoDB it's simply a list:

{
  "name": "Acme s.r.o.",
  "addresses": [
    { "type": "billing",  "street": "King Street 42", "city": "Brno" },
    { "type": "shipping", "street": "Mill Road 7",    "city": "Ostrava" }
  ]
}

Then someone wants an email on the address

Invoices should go to accounting. One address in ten has such an email. SQL Server:

ALTER TABLE CustomerAddresses ADD Email nvarchar(254) NULL;

Plus a migration script, the entity, the DTO, all deployed in the right order. And a column that stays empty in 90% of rows forever. In MongoDB you write email into the one address that has it. Nothing else changes.

And then the customer wants two emails

“invoices@ and accounting@, both please.” This is where the SQL model really hurts:

Email2 column And Email3 next year. Every search by email now checks three columns.
Both in one cell "a@x; b@x". Goodbye index, goodbye validation, hello LIKE '%…%'.
New table The right way, and the expensive one: move the data, add a join to every query that shows an email.

In MongoDB the email simply becomes a list:

{ "type": "billing", "city": "Brno",
  "emails": ["[email protected]", "[email protected]"] }

And an index on that list finds the customer by any of the emails, as fast as before:

db.customers.createIndex({ "addresses.emails": 1 })
db.customers.find({ "addresses.emails": "[email protected]" })

“But without joins, the data gets duplicated and goes out of sync”

This is what everyone told me before the switch. Look at what the invoice document actually copies:

  1. The address on an invoice must not change

    The customer moves in June. In the SQL model above, the March invoice printed again in July shows the new address, because it points to a row that was updated. That's not a normalized invoice. That's a wrong one.

    every real SQL schema copies the address into the invoice anyway. Same with the price on an invoice item: nobody joins today's price from Products. That isn't duplication, it's a snapshot. MongoDB just makes it the natural thing.

  2. What must follow is small and rare

    The customer changes its name. Paid invoices keep the old one (see above). Open ones should follow:

    db.invoices.updateMany(
      { "customer._id": acmeId, status: "open" },
      { $set: { "customer.name": "Acme Group s.r.o." } }
    )

    copy only fields that rarely change, like a name. Never things that change every minute, like stock or a balance. Those stay in one place and you look them up.

  3. Not everything goes into one document

    The famous “Why You Should Never Use MongoDB” article (2013) was about a social network, where everything points to everything. Stuffing that into documents really does go wrong.

    embed what belongs to the parent and is read with it (items, addresses). Point to what has a life of its own (the customer, the product).

Away with the bogeyman: “no fixed schema”

The schema doesn't disappear. It moves to where you already have one: your classes. C#:

public record Invoice(ObjectId Id, string Number, DateTime IssuedAt, string Status,
                      CustomerRef Customer, List<InvoiceItem> Items);
public record Address(string Street, string City, string Zip, List<string>? Emails = null);

var invoices = db.GetCollection<Invoice>("invoices");
var open = await invoices.Find(i => i.Customer.Id == customerId && i.Status == "open")
                         .ToListAsync();

TypeScript:

interface Invoice {
  number: string;
  status: "open" | "paid";
  customer: { _id: ObjectId; name: string; address: Address };
  items: InvoiceItem[];
}

const invoices = db.collection<Invoice>("invoices");
await invoices.find({ status: "payed" });   // compile error: not "open" | "paid"

Rename a property and the compiler finds every query. And if you want the database itself to say no to bad data (from scripts, other services, a colleague in a hurry), it can:

db.runCommand({ collMod: "invoices", validator: { $jsonSchema: {
  required: ["number", "customer", "items"]
} } })

The difference from SQL: a new field needs no migration window. No migration scripts that must run before the deploy, after the deploy, and never twice. Old documents just don't have the field yet, and your class says what that means (Emails = null).

Thinking about it?

Don't rewrite the whole system over a weekend. Take one new feature, or the part that hurts most in SQL (lots of optional columns, lists squeezed into rows, a table nobody dares to alter) and start there. Most of what you know comes along:

Would I still pick SQL for something? For data that is a web of relations by nature, maybe. For the usual business app (customers, orders, invoices, settings) I haven't looked back.

The one habit I kept: tables

After fifteen years in SQL Server Management Studio, a wall of JSON is not how I want to look at data. So BsonJet shows a collection as a grid, with a nested object (the address) unfolded as a row under its document:

BsonJet table view: orders as rows, each order's nested address shown as a second row with city, zip and country
Documents as rows, the nested address under each one. Sort, filter, edit a cell.

And the other SSMS reflex, “which indexes are useless?”, has an answer too: index usage summed over all servers of the replica set, unused ones first.

BsonJet index usage: 16 indexes, 15 of them unused, taking 53.3 MB
An index unused on one server may be busy on another. This counts all of them.

Coming from SQL Server? Have a look around.

The full version is free for personal use, no registration.

Download for Windows, macOS or Linux