← Back to the section

For a long time MongoDB had a reputation as "the database that's fast but loses data". That was true for early versions: the journal was not enabled by default, there were no multi-document transactions, and write acknowledgement was not required by default. Almost everything has changed since then — and today MongoDB gives you full ACID guarantees, as long as you know how to use them.

In this article we'll look at what exactly MongoDB guarantees today, at which level, and where people most often go wrong.

Atomicity: a single document is always atomic

In relational databases an "atomic operation" is a transaction you have to open explicitly. In MongoDB atomicity is built in at the level of a single document by default.

What this means: the operations updateOne(), findOneAndUpdate(), replaceOne() are applied all or nothing. Even if the document is complex and several fields are updated at once — there is no intermediate state.

db.product.updateOne(
    { _id: 3 },
    { $set: { price: 180 }, $push: { priceHistory: { ts: new Date(), price: 150 } } }
);
// Either both price and priceHistory are updated — or neither of them is.

Operations on arrays inside a document — $push, $pull, $set — are also atomic.

When a single document is not enough: if you need to atomically update two different documents (for example, move a product to another category and record that in a journal), a single operation won't cut it. This is exactly where transactions come in — more on those below.

Durability: how MongoDB avoids losing data

MongoDB used to be able to lose the most recent writes on a crash — the journal (the analogue of the WAL in PostgreSQL) was not enabled by default. Now the WiredTiger engine writes changes to the journal before applying them to the data pages.

By default the journal is flushed to disk every 100 ms. This is a trade-off: on a crash you can lose up to 100 ms of work, but write speed is higher.

To rule out data loss completely: use j: true in the write concern — then the driver reports success only after the data has been physically written to disk.

Write concern — when to consider a write successful

This is the key parameter for reliability. Write concern sets the condition under which the driver considers an operation complete.

In a replica set it consists of two parts:

  • w — how many replicas must acknowledge the write;
  • j — whether to wait for the journal to be flushed to disk.
SettingWhat it guaranteesWhen to use
{ w: 1 }acknowledged by the primary nodelogs, metrics, non-critical data
{ w: "majority" }acknowledged by a majority of replicasany business logic
{ w: "majority", j: true }majority + journal on diskpayments, audit
{ w: 0 }no acknowledgementmetrics, where loss is acceptable

The main trap with w: 1: the driver gets a "success", but if the primary node goes down before the write is replicated — then when a new leader is elected this write is rolled back. The client thinks the data is saved. It isn't.

// Write to the primary node with w:1
db.product.insertOne({ _id: 8, name: "Chocolate" }, { writeConcern: { w: 1 } });
// → we get "success"

// The primary node fails before replication → when a new leader is elected the write disappears

With w: "majority" this situation doesn't happen: the write is acknowledged only after it has propagated to a majority of replicas.

Set the write concern at the client level:

j := true
wc := writeconcern.Majority()
wc.Journal = &j

client, err := mongo.Connect(ctx, options.Client().
	ApplyURI("mongodb://localhost:27017").
	SetWriteConcern(wc))

Read concern — what we see when reading

Read concern answers the question: "which data should be considered visible to this read?" This matters especially in a replica set, where a read can go to any replica.

There are five levels:

local — the latest known state of the local replica, including data that hasn't been replicated yet. The fastest read, but on a crash and failover you may pick up data that later gets rolled back.

available — similar to local, but in a sharded cluster it may return "orphaned" documents that have logically already moved to another shard. Use it only if speed matters more than accuracy.

majority — returns only data acknowledged by a majority of replicas. Guarantees that what you read won't disappear on failover. The right choice for business logic.

db.product.find({ categoryId: 1 }).readConcern("majority");

linearizable — the strictest level. Guarantees that a read sees the result of all previous successful writes with w: majority. It works only with the primary node and is slower than the others — under the hood an empty write is sent before the read to synchronize. You need it in situations like "check the limit before charging".

snapshot — used only inside transactions. Returns the data as of the moment the transaction started, like a snapshot.

Multi-document transactions

Since MongoDB 4.0 in a replica set and since 4.2 in a sharded cluster, transactions are available: several operations over several documents are applied atomically — "all or nothing".

Example: we move a product from "no category" to the target category and at the same time record it in the change log.

const session = db.getMongo().startSession();
session.startTransaction({
    readConcern:  { level: "snapshot" },
    writeConcern: { w: "majority", j: true }
});

try {
    const products = session.getDatabase("shop").product;
    const journal  = session.getDatabase("shop").categoryChangeLog;

    products.updateOne({ _id: 6 }, { $set: { categoryId: 3 } });
    journal.insertOne({ productId: 6, from: null, to: 3, ts: new Date() });

    session.commitTransaction();
} catch (e) {
    session.abortTransaction();
    throw e;
} finally {
    session.endSession();
}

The same transaction through the Go driver:

txOpts := options.Transaction().
	SetReadConcern(readconcern.Snapshot()).
	SetWriteConcern(wc) // wc — the same { w: "majority", j: true }

session, err := client.StartSession()
if err != nil {
	return err
}
defer session.EndSession(ctx)

_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
	products := client.Database("shop").Collection("product")
	journal := client.Database("shop").Collection("categoryChangeLog")

	if _, err := products.UpdateOne(sc,
		bson.M{"_id": 6},
		bson.M{"$set": bson.M{"categoryId": 3}}); err != nil {
		return nil, err
	}
	if _, err := journal.InsertOne(sc, bson.M{
		"productId": 6, "from": nil, "to": 3, "ts": time.Now(),
	}); err != nil {
		return nil, err
	}
	return nil, nil
}, txOpts)

What's important to know about transactions in MongoDB:

  • The default lifetime is 60 seconds. MongoDB cancels long transactions automatically.
  • The maximum size is 16 MB in the operation log. Transactions are not suited for large batch operations.
  • On a conflict MongoDB returns a TransientTransactionError — this is a signal to retry the operation. The WithTransaction() method in most drivers does the retry automatically.
  • Transactions in MongoDB are more expensive in terms of performance than in PostgreSQL. If the task can be solved with a single atomic operation on a single document — that's preferable.

Causal consistency — "read what I just wrote"

A typical problem when working with a replica set: a user updates their profile, immediately opens the profile page — and sees the old data. The reason: the write went to the primary node, but the read went to a replica that hasn't received the update yet.

Causal consistency solves this. After each write the driver passes along a timestamp, and the next read waits until the replica catches up to that timestamp. It works through a session token, with no global performance impact.

const session = db.getMongo().startSession({ causalConsistency: true });

session.getDatabase("shop").product.updateOne(
    { _id: 3 }, { $set: { price: 180 } }
);

// This find will see the update, even if it goes to a replica:
session.getDatabase("shop").product.findOne({ _id: 3 });

Through the Go driver:

sessOpts := options.Session().SetCausalConsistency(true)

session, err := client.StartSession(sessOpts)
if err != nil {
	return err
}
defer session.EndSession(ctx)

err = mongo.WithSession(ctx, session, func(sc mongo.SessionContext) error {
	products := client.Database("shop").Collection("product")

	if _, err := products.UpdateOne(sc,
		bson.M{"_id": 3},
		bson.M{"$set": bson.M{"price": 180}}); err != nil {
		return err
	}

	// The read will see the update:
	var updated bson.M
	return products.FindOne(sc, bson.M{"_id": 3}).Decode(&updated)
})

It's enabled per-session since MongoDB 3.6+. If the application reads data right after its own write — this is the mode you need.

Consistency and data validation

MongoDB does not check foreign keys: a document with categoryId: 99 will be saved even if category 99 doesn't exist. This is a deliberate design decision — referential integrity is ensured either through embedded documents (embed) or at the application level.

What MongoDB does check:

  • JSON Schema validators — if a validator is attached to a collection, a document that violates the schema is rejected.
  • Unique indexesunique: true guarantees the uniqueness of a field's value.
  • The _id field — mandatory and unique within a collection.
db.runCommand({
    collMod: "product",
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["price", "name"],
            properties: {
                price: { bsonType: "number", minimum: 0 },
                name:  { bsonType: "string", minLength: 1 }
            }
        }
    },
    validationLevel: "strict"
});

In short

  • A single document in MongoDB is always atomic — no intermediate code will see a partial update.
  • Multiple documents atomically — only through transactions (MongoDB 4.0+).
  • Write concern w: "majority" is the right choice for business data; w: 1 may lose an acknowledged write when the primary node fails.
  • j: true adds the guarantee that the journal is physically written to disk — needed for payments and audit.
  • Read concern majority — data that won't disappear on failover; linearizable — for critical checks.
  • Causal consistency — solves the "just wrote it but don't see it" problem when reading from a replica.
  • Transactions in MongoDB are more expensive than in PostgreSQL; if the task is solved by a single atomic operation — do it that way.
  • MongoDB does not check foreign keys — referential integrity is held either by an embed structure or by the application.
  • Replication and sharding in MongoDB — how a replica set works, which read concerns make sense on secondary nodes, transactions in a sharded cluster.
  • Document modeling — why in MongoDB you can often do without transactions through embedded documents.
  • ACID and isolation levels in PostgreSQL — a comparison of the two databases' approaches.