MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
Salesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A single transaction cannot DML both kinds — you'll have to push one of them into an asynchronous context.
Also seen asMIXED_DML_OPERATION·DML operation on setup object is not allowed after you have updated a non-setup object·MIXED_DML
A trigger that auto-provisions a Salesforce User account when a Contact is converted fails with MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa). The User insert fires, then the Contact update fires (from the same trigger context), and the platform throws because the transaction is now trying to write both setup and non-setup objects. The fix involves moving one side into a separate transaction.
The setup vs non-setup divide
Salesforce splits all objects into two categories.
Setup objects are the platform's configuration entities. They define users, groups, permissions, profiles, queues, and the access-control machinery of the org. Examples: User, UserRole, Profile, PermissionSet, PermissionSetAssignment, Group, GroupMember, Queue, QueueSobject, ObjectPermissions, FieldPermissions.
Non-setup objects are everything else. Every standard data object (Account, Contact, Case, Opportunity, Lead, Task) plus every custom object you create.
The platform refuses to DML both categories in the same transaction. If your code does an insert/update/delete on a non-setup object and then attempts a DML on a setup object (or vice versa), the second DML throws MIXED_DML_OPERATION. The error message even names which side came first.
The reason is locking. Setup objects participate in the platform's permissions and access-control caching. Mixing their writes with data writes in a single transaction creates contention with cached permissions that the platform doesn't want to handle inline.
The broken example
A trigger that wants to grant a permission set to a contact's owner when the contact is approved:
trigger ContactTrigger on Contact (after update) {
List<PermissionSetAssignment> psas = new List<PermissionSetAssignment>();
Id psId = [SELECT Id FROM PermissionSet WHERE Name = 'Approved_Contact_Owner' LIMIT 1].Id;
for (Contact c : Trigger.new) {
Contact old = Trigger.oldMap.get(c.Id);
if (c.Approval_Status__c == 'Approved' && old.Approval_Status__c != 'Approved') {
psas.add(new PermissionSetAssignment(
AssigneeId = c.OwnerId,
PermissionSetId = psId
));
}
}
if (!psas.isEmpty()) {
insert psas; // Throws MIXED_DML because Contact (non-setup) was updated to trigger this
}
}
The Contact update that triggered the trigger was DML on a non-setup object. The insert psas is DML on a setup object (PermissionSetAssignment). Same transaction. Refused.
The fix: split the work across transactions
Two approaches work, depending on whether the platform context lets you escape with @future (legacy) or Queueable (modern).
Modern: Queueable Apex. Move the setup-side DML to a queueable that runs in its own transaction:
trigger ContactTrigger on Contact (after update) {
List<Id> approvedOwnerIds = new List<Id>();
for (Contact c : Trigger.new) {
Contact old = Trigger.oldMap.get(c.Id);
if (c.Approval_Status__c == 'Approved' && old.Approval_Status__c != 'Approved') {
approvedOwnerIds.add(c.OwnerId);
}
}
if (!approvedOwnerIds.isEmpty()) {
System.enqueueJob(new GrantApprovedOwnerPermission(approvedOwnerIds));
}
}
public class GrantApprovedOwnerPermission implements Queueable {
private List<Id> ownerIds;
public GrantApprovedOwnerPermission(List<Id> ids) { this.ownerIds = ids; }
public void execute(QueueableContext qc) {
Id psId = [SELECT Id FROM PermissionSet WHERE Name = 'Approved_Contact_Owner' LIMIT 1].Id;
List<PermissionSetAssignment> psas = new List<PermissionSetAssignment>();
for (Id ownerId : ownerIds) {
psas.add(new PermissionSetAssignment(AssigneeId = ownerId, PermissionSetId = psId));
}
if (!psas.isEmpty()) insert psas;
}
}
The queueable runs in a fresh transaction. The Contact update that triggered the trigger is committed; the new transaction only does setup-object DML. No mixing.
Legacy: @future. Older codebases use @future for the same pattern:
trigger ContactTrigger on Contact (after update) {
List<Id> ownerIds = new List<Id>();
for (Contact c : Trigger.new) {
if (c.Approval_Status__c == 'Approved' && Trigger.oldMap.get(c.Id).Approval_Status__c != 'Approved') {
ownerIds.add(c.OwnerId);
}
}
if (!ownerIds.isEmpty()) {
GrantPermissionService.grantApprovedOwnerAccess(ownerIds);
}
}
public class GrantPermissionService {
@future
public static void grantApprovedOwnerAccess(List<Id> ownerIds) {
Id psId = [SELECT Id FROM PermissionSet WHERE Name = 'Approved_Contact_Owner' LIMIT 1].Id;
List<PermissionSetAssignment> psas = new List<PermissionSetAssignment>();
for (Id ownerId : ownerIds) {
psas.add(new PermissionSetAssignment(AssigneeId = ownerId, PermissionSetId = psId));
}
if (!psas.isEmpty()) insert psas;
}
}
Both Queueable and @future work. Queueable is the preferred modern choice because it supports chaining, state, and HTTP callouts. New code should use Queueable.
The complete setup/non-setup reference
A full list of objects classified as "setup" (this is what the platform refuses to mix with non-setup DML):
- User and UserRole
- Profile and PermissionSet
- PermissionSetAssignment and PermissionSetLicense
- Group, GroupMember, Queue, QueueSobject
- ObjectPermissions, FieldPermissions
- SetupEntityAccess
- NetworkMember (Community membership)
- Some others related to chatter and feed configuration
Everything else (standard data objects and custom objects) is non-setup.
If your code does DML on multiple setup objects in the same transaction, that's fine. Same for multiple non-setup DML. The constraint is only on mixing.
The fixed example, end to end
A complete approval-handling pattern that respects the mixed-DML rule:
public class ContactApprovalService {
public static void handleApprovedContacts(List<Contact> contacts, Map<Id, Contact> oldMap) {
List<Id> approvedOwnerIds = new List<Id>();
List<Contact> toLog = new List<Contact>();
for (Contact c : contacts) {
Contact old = oldMap.get(c.Id);
if (c.Approval_Status__c == 'Approved' && old.Approval_Status__c != 'Approved') {
approvedOwnerIds.add(c.OwnerId);
toLog.add(c);
}
}
if (approvedOwnerIds.isEmpty()) return;
// Non-setup work happens inline; it's already part of the trigger context.
List<Approval_Log__c> logs = new List<Approval_Log__c>();
for (Contact c : toLog) {
logs.add(new Approval_Log__c(
Contact__c = c.Id,
Approved_At__c = System.now(),
Approved_By__c = UserInfo.getUserId()
));
}
if (!logs.isEmpty()) insert logs;
// Setup work moves async to a separate transaction.
if (!Test.isRunningTest()) {
System.enqueueJob(new GrantApprovedOwnerPermission(approvedOwnerIds));
}
}
}
trigger ContactTrigger on Contact (after update) {
ContactApprovalService.handleApprovedContacts(Trigger.new, Trigger.oldMap);
}
The trigger does its non-setup work inline (logging the approval) and enqueues the setup work for a separate transaction. Both DMLs complete; neither violates the rule.
Why the rule exists
The platform's permissions cache is rebuilt whenever a setup object changes. The cache lives in shared infrastructure; rebuilding it requires coordinating with other transactions. Mixing the rebuild with a non-setup transaction can produce stale-cache reads or deadlocks.
The serial-deploy rule for orgs and the mixed-DML rule for transactions share the same underlying design intent: keep configuration changes out of the data-write critical path.
A subtle exception
A handful of "non-setup" objects participate in setup-like behavior and trigger this error in surprising ways. The most common: Site (a community site object). Some platform internal objects behave like setup objects despite their naming. The error message names the specific offender, so you can identify which side of the rule you crossed.
If you're getting MIXED_DML on what looks like two non-setup objects, check the platform's classification. The Salesforce Help page on the mixed-DML rule has the authoritative classification list.
Test patterns
Apex tests can DML setup objects via System.runAs to switch user context:
@isTest
static void grantPermission_succeedsAsync() {
User u = TestUserFactory.create();
Contact c = new Contact(LastName = 'Test', OwnerId = u.Id, Approval_Status__c = 'Draft');
insert c;
Test.startTest();
c.Approval_Status__c = 'Approved';
update c;
Test.stopTest(); // Async work runs here.
Integer assigned = [SELECT COUNT() FROM PermissionSetAssignment
WHERE AssigneeId = :u.Id
AND PermissionSet.Name = 'Approved_Contact_Owner'];
System.assertEquals(1, assigned);
}
The test verifies that the async grant happens correctly after the contact approval. Test.stopTest runs any queued queueables, so the assertion runs against the post-async state.
Patterns that don't help
A few "fixes" that look right but aren't:
- Wrapping the DML in try/catch. The platform still throws; the catch just suppresses the visible error while the transaction rolls back.
- Using
Database.insert(records, false)(partial-success). The platform refuses the call before evaluating any individual record; partial-success doesn't apply. - Putting the DML in a
beforetrigger instead ofafter. Same transaction context; same rule applies. - Putting both DMLs in the same
System.runAsblock. Doesn't change the transaction context.
The only reliable fix is to push one side into a separate transaction via Queueable or @future.
The User-creation special case
Creating a User is one of the most common setup-side operations and produces this error often. A trigger on Account that auto-creates a User for the account's primary contact:
trigger AccountTrigger on Account (after insert) {
// ... fetches contact info, then ...
insert new User(...); // MIXED_DML; Account was just inserted
}
The fix: push the User insert to async.
trigger AccountTrigger on Account (after insert) {
Set<Id> contactIds = new Set<Id>();
for (Account a : Trigger.new) {
if (a.Primary_Contact__c != null) contactIds.add(a.Primary_Contact__c);
}
if (!contactIds.isEmpty() && !Test.isRunningTest()) {
System.enqueueJob(new ProvisionUserQueueable(contactIds));
}
}
The queueable runs in a fresh transaction and can insert Users without the mixed-DML constraint.
A nuance: tests that exercise mixed-DML scenarios
Test.runAs(systemUser) lets test code temporarily switch context, which can be useful for setup-object DML in tests. In some test patterns, you call Test.startTest(), then runAs a System Administrator, then do the setup-side DML. The runAs context isolates the setup work so it doesn't interfere with the test's main user context.
@isTest
static void provisioning_createsUser() {
Account a = new Account(Name = 'Test', Primary_Contact__c = c.Id);
Test.startTest();
insert a;
Test.stopTest(); // Async runs here.
Integer userCount = [SELECT COUNT() FROM User WHERE Account_Id__c = :a.Id];
System.assertEquals(1, userCount);
}
For test setup that needs both data and setup objects in the same test, structure the test to:
- Run setup-only DML in a
System.runAsblock first. Test.startTest.- Run data DML.
Test.stopTest.
The pattern keeps the mixed-DML rule satisfied within tests.
What the platform doesn't tell you
The error message names the two object kinds involved but doesn't show you the stack trace of which line did which DML. Diagnosing in complex codebases requires:
- Read the trigger context. Was the trigger fired by a data DML or a setup DML?
- Walk through the trigger's logic. What other DMLs does it issue?
- Trace handlers, services, and helper methods that the trigger calls. Where's the second DML coming from?
For a codebase with deep service layers, this can take time. A pragmatic shortcut: turn on Apex debug logs with all logging set to FINE for "DML Statements," reproduce the error, and inspect the log. The log shows every DML in order, so you can see which two DMLs are colliding.
Refactoring to consolidate setup work
In larger orgs, setup-object DML happens across many triggers and services. Each one becomes a potential mixed-DML failure when called from a non-setup transaction. The pattern that scales is to consolidate all setup work into a small set of dedicated services that always run async.
public class UserProvisioningService {
public static void enqueueGrantPermission(Id userId, String permissionSetName) {
System.enqueueJob(new GrantPermissionQueueable(userId, permissionSetName));
}
public static void enqueueCreateGroupMember(Id groupId, Id userId) {
System.enqueueJob(new CreateGroupMemberQueueable(groupId, userId));
}
// ... etc.
}
Every place in the codebase that needs setup DML calls one of these methods. The methods all enqueue queueables. The mixed-DML rule never bites because setup work is always async.
The architectural cost is one Queueable class per setup operation type. The benefit is that every team in the org uses the same pattern, and new code can't accidentally introduce a mixed-DML failure.
When the same record is both setup and non-setup
Some objects are "mostly" non-setup but have specific fields that interact with setup logic. The classic example: User. The User object itself is setup. But a User record has fields that point to non-setup objects (Account, Contact), and updating those fields might cascade into non-setup changes through workflows.
In practice, treat the entire User object as setup. Don't try to update User fields from a non-setup-trigger context. Push to async every time.
Quirks in scratch orgs vs production
Scratch orgs created with the EnableSetPasswordInApi feature can sometimes appear to allow mixed DML in patterns that fail in production. This is misleading; the scratch org's relaxed enforcement isn't representative of production behavior.
Always test the mixed-DML path in an environment with production-equivalent feature flags. A test that passes in a permissive scratch org and fails in production is a frustrating discovery; running validation against a more strict environment up front catches it.
A common newcomer mistake
A pattern that looks reasonable but isn't: putting the setup DML before the non-setup DML, in the hope that "if setup comes first, the rule doesn't apply."
trigger ContactTrigger on Contact (before insert) {
// Setup DML attempted first
insert new PermissionSetAssignment(...); // Still throws MIXED_DML
// Followed by non-setup DML implicitly via the Contact insert that triggered us
}
The rule is symmetric. Setup-then-non-setup and non-setup-then-setup are both refused. The order within the transaction doesn't matter.
The fix is the same: push one side to async. The asymmetry doesn't help.
A useful diagnostic
For long-running incidents where the mixed-DML is buried in a service hierarchy:
System.debug('Setup DML count: ' + Limits.getDmlStatements()
+ '; SObject types in transaction: traced via debug logs');
While not a direct check, observing the DML statement count and the transaction's Limits.getDmlStatements() over time helps narrow which DML is happening when. Pair with reading the debug log to see the SObject types.
A note on Salesforce Site users
Salesforce Site contexts have their own quirks with mixed DML. A guest user accessing a Site can sometimes succeed at DML combinations that fail elsewhere, because the platform applies different rules to guest contexts. Don't rely on this behavior; explicit setup-async patterns survive Site quirks gracefully and avoid subtle bugs when your code is invoked from a non-Site context.
For Site-specific provisioning that must happen during a guest user's session, the right pattern is to queue the work via a Queueable and confirm the deferred work completes before the user sees a confirmation. The UI shows a "Processing..." spinner; the queueable does the setup DML; the spinner clears when done.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…
System.DmlException: Insert failed (or Update / Upsert / Delete failed)
ApexA DML statement (insert/update/upsert/delete) failed. The exception message contains a "first exception on row N" line that tells you which …