DocsHub
Transactions

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 transaction

When 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:

  1. Add courseId to the student's courseIds array
  2. Increment totalStudents on the course
  3. Create a new document in the enrollments collection

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:

LevelBehavior
"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:

ValueBehavior
{ 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

ManualwithTransaction
CommitYou call commitTransaction()Automatic
AbortYou call abortTransaction()Automatic
Transient error retryYou implement it yourselfAutomatic
ControlFullLess — but enough for most cases
Use whenLearning, special casesProduction 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.

On this page