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.
With w: 1 the client gets success as soon as the write lands on the primary alone. If that node dies before replication, the new primary is elected among nodes that never saw the write — and an acknowledged write is rolled back. With w: majority the answer comes back only after two nodes out of three: any new primary has to come from that majority, so the write survives the failure. The third node catches up later, which no longer affects the guarantee.
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. Since MongoDB 6.1 the journal cannot be turned off at all: the --nojournal option is gone.
Write concern — when to consider a write successful
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.
| Setting | What it guarantees | When to use |
|---|---|---|
{ w: 1 } | acknowledged by the primary node | logs, metrics, non-critical data |
{ w: "majority" } | acknowledged by a majority of replicas | any business logic |
{ w: "majority", j: true } | majority + journal on disk | payments, audit |
{ w: 0 } | no acknowledgement | metrics, where loss is acceptable |
Since MongoDB 5.0 the default write concern is majority. That means w: 1 today only shows up where somebody set it explicitly, or in a set with arbiters where a majority of data-bearing nodes cannot be reached.
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.
db.product.insertOne({ _id: 8, name: "Chocolate" }, { writeConcern: { w: 1 } });
The difference shows up on a small model of a replica set: nodes are lists of records, the answer goes out as soon as the requested number of nodes has taken the write, and then the primary dies.
live example
import java.util.ArrayList;
import java.util.List;
public class WriteConcernDemo {
public static void main(String[] args) {
System.out.println(failover("w: 1 ", 1));
System.out.println(failover("w: majority", 2));
}
static String failover(String concern, int acksNeeded) {
List<List<String>> nodes = List.of(new ArrayList<>(), new ArrayList<>(), new ArrayList<>());
int acked = 0;
for (List<String> node : nodes) {
if (acked == acksNeeded) {
break;
}
node.add("price=180");
acked++;
}
List<String> newPrimary = nodes.get(1);
String after = newPrimary.contains("price=180") ? "the write is there" : "the write is gone";
return concern + " — acknowledged by " + acked + " of " + nodes.size()
+ " nodes; the primary died, node 2 took over: " + after;
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Free week →
The write lives exactly where it landed by the time of the answer: with w: 1 that is a single node, and once it dies the success the client already got turns out to be a lie. With w: "majority" the new primary always comes from the majority that already holds the write.
Set the write concern at the client level:
MongoClientSettings settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString("mongodb://localhost:27017"))
.writeConcern(WriteConcern.MAJORITY.withJournal(true))
.build();
MongoClient client = MongoClients.create(settings);
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 — returns the data as of a fixed point in time, like a snapshot. Most often it is used inside transactions, but since MongoDB 5.0 plain reads can ask for it too — handy when several queries in a row have to see a consistent picture.
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 Java driver:
TransactionOptions txOpts = TransactionOptions.builder()
.readConcern(ReadConcern.SNAPSHOT)
.writeConcern(WriteConcern.MAJORITY.withJournal(true))
.build();
try (ClientSession session = client.startSession()) {
session.withTransaction(() -> {
MongoCollection<Document> products = client
.getDatabase("shop").getCollection("product");
MongoCollection<Document> journal = client
.getDatabase("shop").getCollection("categoryChangeLog");
products.updateOne(session,
new Document("_id", 6),
new Document("$set", new Document("categoryId", 3)));
journal.insertOne(session, new Document()
.append("productId", 6)
.append("from", null)
.append("to", 3)
.append("ts", new java.util.Date()));
return null;
}, txOpts);
}
What's important to know about transactions in MongoDB:
- The default lifetime is 60 seconds. MongoDB cancels long transactions automatically.
- A large transaction runs into the WiredTiger cache: if the changes do not fit, MongoDB 6.2 and later answers
TransactionTooLargeForCache, and a retry does not help. Batch work gets split into parts. - On a conflict MongoDB returns a
TransientTransactionError— this is a signal to retry the operation. ThewithTransaction()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 Java driver:
ClientSessionOptions sessionOpts = ClientSessionOptions.builder()
.causallyConsistent(true)
.build();
try (ClientSession session = client.startSession(sessionOpts)) {
var products = client.getDatabase("shop").getCollection("product");
products.updateOne(session,
new Document("_id", 3),
new Document("$set", new Document("price", 180)));
// The read will see the update:
Document updated = products.find(session, new Document("_id", 3)).first();
}
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 indexes —
unique: trueguarantees the uniqueness of a field's value. - The
_idfield — 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 — nobody sees a partial update; several documents atomically — only through transactions (4.0+).
- Write concern
w: "majority"is the choice for business data: such a write survives the loss of the primary, whilew: 1can disappear after the client already got a "success". j: trueadds 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 checks like "is the balance still enough". - Causal consistency solves the "just wrote it but don't see it" problem when reading from a replica, and is enabled per session.
- A transaction costs more than a single atomic operation on one document, and MongoDB does not check foreign keys: referential integrity is held by an embed structure or by the application.
What to read next
- 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.
- PostgreSQL or MongoDB — when the guarantees of one database matter more than the flexibility of the other.