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,upsertcount. - 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:
@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:
@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:
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.
# 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