Salesforce DML status code

MIXED_DML_OPERATION, DML operation on setup object is not permitted after you have updated a non-setup object (or vice versa)

MIXED_DML_OPERATION — the setup / non-setup split, and how to work with it

Salesforce sorts every object into setup (User, Group, PermissionSet and friends) or non-setup (your business data). One transaction may write one kind or the other, never both. The fix is not a permission or a config flag — it is moving one of the two writes into a different transaction.

Quick answer

In a test, wrap the setup-object DML in System.runAs(...) — the block gets its own DML context and the restriction lifts. In production code, move the setup-object DML into an @future method or a chained Queueable so it commits in a separate transaction. Queries never count; only DML does.

Why the platform refuses it

Setup objects describe who can do what: users, roles, groups, queues, permission sets and their assignments, object and field permissions. Non-setup objects hold business data: Account, Contact, your custom objects, everything with a record page.

The two are persisted through different paths and cannot be rolled back as a single unit. If Salesforce allowed both in one transaction and the transaction failed halfway, it could end up with a committed permission grant and no data — or data and no grant. Rather than expose that, the platform rejects the second write outright.

Three properties of the rule matter in practice:

  • It is per-transaction, not per-statement. Once a non-setup DML has run, any setup-object DML later in the same transaction fails — even a hundred lines and three classes away.
  • Reads are free. Querying Users, PermissionSets or GroupMembers alongside data DML is always fine. Only insert, update, delete, upsert count.
  • It is symmetric. Setup-then-data fails exactly as data-then-setup does, which is what the "(or vice versa)" in the message is telling you.

Which objects are setup objects

The list you will actually collide with:

  • User — by far the most common, because test data factories create test users.
  • UserRole, UserTerritory, Territory
  • Group and GroupMember — public groups and queue membership.
  • QueueSobject — which objects a queue accepts.
  • PermissionSet and PermissionSetAssignment — the second most common, from just-in-time permission grants.
  • ObjectPermissions, FieldPermissions, SetupEntityAccess

Two objects that people expect to be setup objects and are not: Profile is read-only from Apex entirely, so it never produces this error, and custom settings / custom metadata are not setup objects — custom metadata has its own separate deployment path (Metadata.Operations.enqueueDeployment) rather than DML.

The causes, ranked

1. Test data followed by a test user

The overwhelming majority of real occurrences. A @testSetup or test method creates Accounts, then creates a User to run part of the assertion under:

apex
@isTest
static void testOwnerReassignment() {
  Account a = new Account(Name = 'Acme');
  insert a;                                   // non-setup DML

  Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
  User u = new User(
    Alias             = 'tuser',
    Email             = 'tuser@example.invalid',
    LastName          = 'Tester',
    Username          = 'tuser' + DateTime.now().getTime() + '@example.invalid',
    ProfileId         = p.Id,
    EmailEncodingKey  = 'UTF-8',
    LanguageLocaleKey = 'en_US',
    LocaleSidKey      = 'en_US',
    TimeZoneSidKey    = 'America/Los_Angeles'
  );
  insert u;                                   // MIXED_DML_OPERATION
}

2. A trigger that grants permissions in response to a data change

"When a Contact is marked as a portal user, assign the permission set." The trigger runs inside the save pipeline of a non-setup DML, so its insert of a PermissionSetAssignment is by definition the second write. Same shape for a trigger that adds a GroupMember when a record changes owner.

3. A @testSetup that seeds both kinds

Worse than case 1 because the failure appears in every test method in the class at once, with a stack trace pointing at setup rather than at the test. Same fix, applied inside @testSetup.

4. A Queueable or Batch execute() doing both

Async does not exempt you — a Queueable's execute() is one transaction like any other. The fix is to chain: do the data DML, then System.enqueueJob a second Queueable for the setup-object work.

Fix 1 — System.runAs, for tests

System.runAs exists to change the running user, but it has a second documented property that is the actual reason most codebases use it: DML inside the block runs in its own context, so the mixed-DML restriction does not apply across the boundary. Running as the current user is a no-op impersonation and still gets you the split:

apex
@isTest
static void testOwnerReassignment() {
  Account a = new Account(Name = 'Acme');
  insert a;                                   // non-setup DML

  User u = TestDataFactory.buildStandardUser();

  // Impersonating the current user is enough — the block is what matters
  System.runAs(new User(Id = UserInfo.getUserId())) {
    insert u;                                 // setup DML, separate context
  }

  System.runAs(u) {
    Test.startTest();
    AccountService.reassign(a.Id);
    Test.stopTest();
  }
}

Applies equally in @testSetup. The idiom to standardise on: every method in your test data factory that creates Users, PermissionSetAssignments, or GroupMembers does its own DML inside a System.runAs block, so callers never have to think about it.

Fix 2 — @future or a chained Queueable, for production code

System.runAs is test-only. In real code the setup-object write has to happen in a genuinely separate transaction. An @future method is the smallest change:

apex
public class PortalAccessService {

  // Called from a trigger after the Contact DML has already run.
  // @future defers this to its own transaction, so no mixed DML.
  @future
  public static void grantPortalAccess(Set<Id> userIds, Id permissionSetId) {
    List<PermissionSetAssignment> assignments = new List<PermissionSetAssignment>();

    // Skip anyone who already has it — reassignment throws DUPLICATE_VALUE
    Set<Id> existing = new Set<Id>();
    for (PermissionSetAssignment psa : [
      SELECT AssigneeId FROM PermissionSetAssignment
      WHERE PermissionSetId = :permissionSetId AND AssigneeId IN :userIds
    ]) {
      existing.add(psa.AssigneeId);
    }

    for (Id userId : userIds) {
      if (!existing.contains(userId)) {
        assignments.add(new PermissionSetAssignment(
          AssigneeId      = userId,
          PermissionSetId = permissionSetId
        ));
      }
    }

    if (!assignments.isEmpty()) {
      Database.insert(assignments, false);
    }
  }
}

Constraints worth knowing before you reach for it. @future takes only primitives and collections of primitives as parameters — pass Ids, not SObjects. It cannot be called from another @future method or from a Batch execute. And it gives you no completion signal, so if the caller needs to know whether the grant succeeded, chain a Queueable instead: System.enqueueJob returns a job Id you can poll on AsyncApexJob.

Whichever you choose, use partial-success DML (Database.insert(list, false)) for the setup-object write. The original transaction has already committed by the time it runs; an uncaught exception there loses the grant silently rather than rolling anything back.

What Nimbus does and does not check here

Straight answer: Nimbus does not enforce the setup / non-setup split. It runs Apex locally against an embedded PostgreSQL, and inserting an Account followed by a User in the same local transaction completes without raising MIXED_DML_OPERATION. A green local run is not evidence that a transaction is mixed-DML-safe — that check belongs to the platform, and this is one of the errors you still want a scratch-org or sandbox pass to catch.

What is worth doing locally is verifying the fix. Both remedies are ordinary Apex that Nimbus executes: System.runAs is a first-class statement, so the impersonation, the sharing behaviour inside the block, and the records the block writes all behave; and @future methods are queued and run at Test.stopTest(), so you can assert that the deferred grant actually happened rather than assuming it did.

bash
# Assert the runAs-wrapped factory and the deferred grant both behave
nimbus test "PortalAccessServiceTest.*"

# Full account of a failure inside the async body
nimbus explain PortalAccessServiceTest.testGrantIsDeferred