---
title: "MIXED_DML_OPERATION: setup objects and why they cannot share a transaction"
url: "https://trailassembly.com/mixed-dml-operation/"
topic: "Apex and Development"
published: "2026-09-09T04:46:58+00:00"
updated: "2026-10-06T13:50:59+00:00"
---

# MIXED_DML_OPERATION: setup objects and why they cannot share a transaction

**Straight answer:** you wrote to a setup object and a regular object in the same transaction. Salesforce does not allow that. In a test, wrap the setup-object work in `System.runAs()`. In production code, move one side into a separate transaction with a [Queueable](https://trailassembly.com/uncommitted-work-pending/) or future method.

## Which objects are “setup” objects

The distinction is not about permissions, it is about how these records commit. The common ones:

- `User` and `UserRole`

- `Group` and `GroupMember`

- `PermissionSet`, `PermissionSetAssignment`, `PermissionSetGroup`

- `ObjectPermissions` and `FieldPermissions`

- `QueueSObject`

- `Territory` and related assignment objects

Everything else (Account, Contact, your custom objects) is a non-setup object. Write to one category, then the other, in a single transaction, and you get the exception. The order does not matter. The mixing is what is disallowed.

## Why the platform forbids it

Setup objects participate in a different commit path from ordinary data. Allowing both in one transaction would make it possible to leave the system in a state where a permission change committed but the data change it was meant to accompany did not. Rather than expose that risk, the platform refuses the combination.

Which reframes the error usefully: it is not an arbitrary restriction to be circumvented. It is telling you these two writes belong in separate transactions, and the fix is to put them there.

## The fix in tests

Most people meet this error while writing a test that creates a User and then some data. `System.runAs()` establishes a separate context for the setup-object work:

```apex
@IsTest
static void userSeesOnlyOwnRecords() {
    Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];

    User u = new User(
        Alias = 'tuser',
        Email = 'test.user@example.com.invalid',
        LastName = 'Tester',
        Username = 'test.user.' + DateTime.now().getTime() + '@example.com.invalid',
        ProfileId = p.Id,
        TimeZoneSidKey = 'America/Los_Angeles',
        LocaleSidKey = 'en_US',
        EmailEncodingKey = 'UTF-8',
        LanguageLocaleKey = 'en_US'
    );

    // Setup-object DML isolated here.
    System.runAs(u) {
        insert new Account(Name = 'Owned by test user');
    }
}
```

Two details that save time. Use a genuinely unique Username (a timestamp works) because usernames are unique across every Salesforce org, not just yours, and a hardcoded one will collide the second time the test runs anywhere. And use an invalid email domain so no test run can send mail to a real address.

## The fix in production code

Separate transactions. [Queueable](https://trailassembly.com/apex-choosing-async/) is the modern choice and is easier to test and chain than a future method:

```apex
public class PermissionAssigner implements Queueable {
    private final Set<Id> userIds;
    private final Id permSetId;

    public PermissionAssigner(Set<Id> userIds, Id permSetId) {
        this.userIds = userIds;
        this.permSetId = permSetId;
    }

    public void execute(QueueableContext context) {
        List<PermissionSetAssignment> assignments = new List<PermissionSetAssignment>();
        for (Id uid : userIds) {
            assignments.add(new PermissionSetAssignment(
                AssigneeId = uid,
                PermissionSetId = permSetId
            ));
        }
        insert assignments;
    }
}
```

Called from wherever the non-setup DML happens:

```apex
insert accounts;                                    // non-setup
System.enqueueJob(new PermissionAssigner(userIds, permSetId));   // setup, separate transaction
```

Note what you have accepted by doing this: the permission assignment is now eventually consistent. It happens shortly after, not atomically with, the data change. If a downstream process assumes the assignment already exists, it will intermittently be wrong. Handle that explicitly rather than discovering it in production.

## A trap worth knowing

The exception surfaces at the second DML statement, which means the stack points at innocent code. If your trigger inserts an Account and something further along assigns a permission set, the error appears to come from the permission set line even though the mixing was caused by the earlier insert. Read the whole transaction, not the line the error names.

This also means the error can appear after a change to seemingly unrelated code — someone adds a `GroupMember` insert to a utility method, and a trigger that has worked for two years starts failing.

## Frequently asked

### Does the order of the two writes matter?

No. Either order produces the exception. The combination is what is disallowed.

### Can I use System.runAs() in production code?

No, it is test-context only. Production code needs an actual separate transaction.

### Is Queueable better than a future method here?

Generally yes — it accepts complex types as constructor arguments, can be chained, and is easier to test. Future methods remain fine for simple cases.

### Why does the platform allow this in some tests without runAs?

If the test only ever writes to one category, there is no mixing. The error appears when both happen in the same transaction.

### Does creating a Queueable count as DML?

No. Enqueuing a job is not DML on either category, so it is safe to call from either side.

---

*Reference: [sObjects That Cannot Be Used Together in DML Operations](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_dml_non_mix_sobjects.htm), Salesforce Apex Developer Guide.*

---

Source: https://trailassembly.com/mixed-dml-operation/
Trail Assembly — independent Salesforce write-ups.
