Using Transactions
Learn how to run multi-document transactions in MongoDB using sessions, withTransaction, and error handling.
Using Transactions
Now that you understand what transactions are and why you need them, let's write one. MongoDB transactions work through sessions — you start a session, start a transaction on that session, run your operations, then commit or abort.
The Basic Flow
Every transaction follows this flow:
1. Start a session
2. Start a transaction on the session
3. Run your operations — passing the session to each one
4. If everything succeeded — commit the transaction
5. If anything failed — abort the transactionWhen you abort, MongoDB rolls back all operations that ran inside that transaction. The database returns to exactly the state it was in before the transaction started.
Manual Transaction — Step by Step
This is the explicit way to write a transaction. It gives you full control.
Syntax
const session = client.startSession();
try {
session.startTransaction();
// Run operations — pass session to each one
await collection.insertOne(document, { session });
await collection.updateOne(filter, update, { session });
// All operations succeeded — commit
await session.commitTransaction();
} catch (error) {
// Something failed — roll everything back
await session.abortTransaction();
throw error;
} finally {
// Always end the session
await session.endSession();
}Always pass { session } to every operation inside a transaction. If you forget to pass the session to even one operation, that operation runs outside the transaction — it will not be rolled back if the transaction aborts.
Real Example — Enrolling a Student
Let's write the enrollment transaction for our school system. Enrolling a student requires three writes:
- Add
courseIdto the student'scourseIdsarray - Increment
totalStudentson the course - Create a new document in the
enrollmentscollection
All three must succeed together — or none at all.
const { MongoClient, ObjectId } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
const db = client.db('school');
const students = db.collection('students');
const courses = db.collection('courses');
const enrollments = db.collection('enrollments');
async function enrollStudent(studentId, courseCode) {
const session = client.startSession();
try {
session.startTransaction();
// Step 1 — find the course inside the transaction
const course = await courses.findOne(
{ code: courseCode },
{ session }
);
if (!course) {
throw new Error(`Course ${courseCode} not found`);
}
// Check capacity
if (course.totalStudents >= course.capacity) {
throw new Error(`Course ${courseCode} is full`);
}
// Step 2 — add course to student's enrolled courses
const studentUpdate = await students.updateOne(
{ _id: new ObjectId(studentId) },
{ $push: { courseIds: course._id } },
{ session }
);
if (studentUpdate.matchedCount === 0) {
throw new Error(`Student ${studentId} not found`);
}
// Step 3 — increment total students on the course
await courses.updateOne(
{ _id: course._id },
{ $inc: { totalStudents: 1 } },
{ session }
);
// Step 4 — create enrollment record
await enrollments.insertOne(
{
studentId: new ObjectId(studentId),
courseId: course._id,
courseCode: courseCode,
enrolledAt: new Date(),
status: "active",
grade: null
},
{ session }
);
// All three writes succeeded — commit
await session.commitTransaction();
console.log(`Student ${studentId} enrolled in ${courseCode} successfully`);
} catch (error) {
// Something failed — roll back all three writes
await session.abortTransaction();
console.error('Enrollment failed, transaction rolled back:', error.message);
throw error;
} finally {
await session.endSession();
}
}
// Use it
await enrollStudent("64a1f2c3e4b0a1b2c3d4e5f6", "MATH-10");If the server crashes after step 2 but before step 3, MongoDB rolls back step 2. The student's courseIds array goes back to its original state. The database stays consistent.
withTransaction — The Easier Way
Writing the try/catch/finally manually every time is repetitive. MongoDB provides withTransaction() — a helper that handles commit and abort automatically.
session.withTransaction(async () => {
// your operations here
})withTransaction() automatically:
- Commits the transaction if the callback succeeds
- Aborts and retries if there is a transient error (like a network blip)
- Aborts without retrying if there is a permanent error
Same enrollment — using withTransaction
async function enrollStudent(studentId, courseCode) {
const session = client.startSession();
try {
await session.withTransaction(async () => {
const course = await courses.findOne(
{ code: courseCode },
{ session }
);
if (!course) throw new Error(`Course ${courseCode} not found`);
if (course.totalStudents >= course.capacity) {
throw new Error(`Course ${courseCode} is full`);
}
await students.updateOne(
{ _id: new ObjectId(studentId) },
{ $push: { courseIds: course._id } },
{ session }
);
await courses.updateOne(
{ _id: course._id },
{ $inc: { totalStudents: 1 } },
{ session }
);
await enrollments.insertOne(
{
studentId: new ObjectId(studentId),
courseId: course._id,
courseCode: courseCode,
enrolledAt: new Date(),
status: "active",
grade: null
},
{ session }
);
});
console.log('Enrollment successful');
} catch (error) {
console.error('Enrollment failed:', error.message);
throw error;
} finally {
await session.endSession();
}
}Cleaner — no manual startTransaction, commitTransaction, or abortTransaction. withTransaction() handles all of that for you.
Use withTransaction() in production code. It handles transient errors and retries automatically — something you would have to implement yourself with the manual approach. The manual approach is useful for understanding what is happening under the hood.
Dropping a Student from a Course
The reverse operation — dropping a course — also needs a transaction:
async function dropCourse(studentId, courseCode) {
const session = client.startSession();
try {
await session.withTransaction(async () => {
const course = await courses.findOne(
{ code: courseCode },
{ session }
);
if (!course) throw new Error(`Course ${courseCode} not found`);
// Remove course from student
await students.updateOne(
{ _id: new ObjectId(studentId) },
{ $pull: { courseIds: course._id } },
{ session }
);
// Decrement total students on course
await courses.updateOne(
{ _id: course._id },
{ $inc: { totalStudents: -1 } },
{ session }
);
// Update enrollment record status
await enrollments.updateOne(
{
studentId: new ObjectId(studentId),
courseId: course._id,
status: "active"
},
{
$set: {
status: "dropped",
droppedAt: new Date()
}
},
{ session }
);
});
console.log('Course dropped successfully');
} catch (error) {
console.error('Drop failed:', error.message);
throw error;
} finally {
await session.endSession();
}
}Processing a Fee Payment
Another operation that needs a transaction — recording a fee payment:
async function processFeePayment(studentId, amount) {
const session = client.startSession();
try {
await session.withTransaction(async () => {
// Create payment record
await db.collection('payments').insertOne(
{
studentId: new ObjectId(studentId),
amount: amount,
paidAt: new Date(),
status: "confirmed"
},
{ session }
);
// Update student fee status
await students.updateOne(
{ _id: new ObjectId(studentId) },
{
$set: { hasPaidFees: true },
$inc: { totalFeesPaid: amount },
$push: {
paymentHistory: {
amount: amount,
date: new Date()
}
}
},
{ session }
);
});
console.log('Payment processed successfully');
} catch (error) {
console.error('Payment failed:', error.message);
throw error;
} finally {
await session.endSession();
}
}Transaction Options
You can pass options to startTransaction() or withTransaction():
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
});readConcern
Controls what data the transaction can read:
| Level | Behavior |
|---|---|
"snapshot" | Reads a consistent snapshot of data from the start of the transaction — default for transactions |
"majority" | Reads data that has been acknowledged by a majority of replica set members |
writeConcern
Controls how many replica set members must confirm a write before it is considered successful:
| Value | Behavior |
|---|---|
{ w: 1 } | Primary confirms — fastest |
{ w: "majority" } | Majority of members confirm — safest |
For most applications, the defaults are fine. Only tune these if you have specific durability or performance requirements.
Transactions with Mongoose
If you are using Mongoose, transactions work slightly differently. We cover this in detail in the Mongoose section, but here is a quick look:
const mongoose = require('mongoose');
async function enrollStudent(studentId, courseCode) {
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
const course = await Course.findOne(
{ code: courseCode }
).session(session);
if (!course) throw new Error('Course not found');
await Student.updateOne(
{ _id: studentId },
{ $push: { courseIds: course._id } }
).session(session);
await Course.updateOne(
{ _id: course._id },
{ $inc: { totalStudents: 1 } }
).session(session);
await Enrollment.create(
[{
studentId,
courseId: course._id,
enrolledAt: new Date(),
status: "active"
}],
{ session }
);
});
} finally {
await session.endSession();
}
}With Mongoose, use .session(session) instead of passing { session } as an option — the pattern is slightly different but the concept is identical.
Common Transaction Errors
Transaction numbers are only allowed on a replica set member
You are running MongoDB as a standalone instance. Start a replica set first — rs.initiate() in mongosh, or use Atlas.
Given transaction number does not match You tried to reuse a session that already committed or aborted. Always create a fresh session for each transaction.
Write conflict during transaction
Two transactions tried to write to the same document at the same time. MongoDB aborts one of them. withTransaction() handles this automatically by retrying.
Transaction has been aborted An operation inside the transaction failed and the transaction was aborted. Check the original error for what caused it.
School System Transaction Summary
// Enroll student — 3 writes atomically
await enrollStudent(studentId, "MATH-10");
// Drop course — 3 writes atomically
await dropCourse(studentId, "MATH-10");
// Process fee payment — 2 writes atomically
await processFeePayment(studentId, 15000);Each of these functions wraps multiple writes in a transaction. From the caller's perspective, it is a single clean operation. Internally, all writes happen together or not at all.
Manual vs withTransaction
| Manual | withTransaction | |
|---|---|---|
| Commit | You call commitTransaction() | Automatic |
| Abort | You call abortTransaction() | Automatic |
| Transient error retry | You implement it yourself | Automatic |
| Control | Full | Less — but enough for most cases |
| Use when | Learning, special cases | Production code |
Always end the session in a finally block — whether the transaction succeeded or failed. If you do not call session.endSession(), the session stays open and consumes resources on the server. The finally block guarantees it runs no matter what happens.