System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
You did a DML statement and then tried to make an HTTP callout in the same synchronous transaction. The platform forbids this because the callout could take a long time and leave the database row locks open. Move the callout to `@future(callout=true)` or a queueable.
Also seen asYou have uncommitted work pending·Please commit or rollback before calling out·uncommitted work pending callout
A new feature inserts a Case, then calls an external knowledge base API to suggest articles, and finally updates the Case with the suggestion ids. The code worked end-to-end in unit tests. On the first real save, the user sees System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out. The Case was never inserted, the callout never fired, and the user thinks the page is broken.
What the platform is checking
Apex enforces a strict separation between database transactions and HTTP callouts. Inside a single synchronous transaction, you cannot perform DML (insert, update, upsert, delete) and then make a callout. The order matters. The platform allows callouts before DML, and DML after a callout if no DML has run yet, but it refuses to issue a callout once any pending DML exists in the transaction.
The restriction exists because callouts are slow and unpredictable. An HTTP request can take seconds to return, time out, or fail entirely. While the callout is in flight, the transaction holds database locks on the modified rows. Other users hitting the same records have to wait. If the callout hangs, the entire database transaction hangs with it. Salesforce avoids this class of contention by forbidding the pattern at the runtime level.
The error message is direct: there is work pending in the transaction that has not yet been committed. The platform refuses to issue the HTTP call until that work is either committed by ending the transaction or rolled back by undoing it. Neither option is available mid-transaction in synchronous Apex, which is why the exception fires.
The fix is always architectural. The synchronous flow cannot do both. You have to split the work across transactions or change the order.
The broken example
A controller method that creates a Case, calls the knowledge service, and updates the Case with results:
public class CaseSuggestionController {
@AuraEnabled
public static Case createWithSuggestions(String subject, String description) {
Case c = new Case(Subject = subject, Description = description, Status = 'New');
insert c;
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Knowledge_API/v1/suggest');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, String>{'text' => description}));
Http http = new Http();
HttpResponse res = http.send(req);
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
c.Suggested_Articles__c = String.valueOf(body.get('articleIds'));
update c;
return c;
}
}
The flow is logically clean: insert, call, update. The runtime rejects it because the insert created pending work that has not yet been committed when the callout begins. The CalloutException fires at http.send(req). The Case was never inserted (the transaction rolls back), and no suggestions were ever requested.
A second shape that surprises developers: a controller that touches a custom log record before calling an external service.
public static void notifyExternal(Id opportunityId, String payload) {
Audit_Log__c log = new Audit_Log__c(
Type__c = 'Outbound',
Reference_Id__c = opportunityId,
Status__c = 'Pending'
);
insert log;
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Partner_API/notify');
req.setMethod('POST');
req.setBody(payload);
new Http().send(req);
}
The log insert is well-intentioned. It captures the attempt before the callout. The platform sees pending DML and blocks the callout anyway.
The fix, three paths
Move the callout to an async context. The cleanest fix uses a future method or queueable job. Async methods run in a separate transaction with their own DML cursor. The synchronous transaction can commit normally; the async method makes the callout against fresh state.
public class CaseSuggestionController {
@AuraEnabled
public static Case createCase(String subject, String description) {
Case c = new Case(Subject = subject, Description = description, Status = 'New');
insert c;
CaseSuggestionAsync.requestSuggestions(c.Id, description);
return c;
}
}
public class CaseSuggestionAsync {
@future(callout=true)
public static void requestSuggestions(Id caseId, String description) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Knowledge_API/v1/suggest');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, String>{'text' => description}));
HttpResponse res = new Http().send(req);
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Case c = new Case(Id = caseId, Suggested_Articles__c = String.valueOf(body.get('articleIds')));
update c;
}
}
The synchronous transaction inserts the Case and returns. The future method runs separately, calls the API, and updates the Case with the suggestions. The user sees the new Case immediately. The suggestions appear a few seconds later when the future method finishes.
Make the callout first, then the DML. When the data needed for DML depends on the callout response, the order can be reversed in the synchronous transaction. Callout first, parse the response, then DML.
public static Case createCaseWithSuggestions(String subject, String description) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Knowledge_API/v1/suggest');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, String>{'text' => description}));
HttpResponse res = new Http().send(req);
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String articleIds = String.valueOf(body.get('articleIds'));
Case c = new Case(
Subject = subject,
Description = description,
Status = 'New',
Suggested_Articles__c = articleIds
);
insert c;
return c;
}
The callout runs first while no DML is pending. The response is captured, and the Case is inserted with the suggestion field already populated. The flow now compiles into a single transaction with no exception. This works when the business logic allows it; if the external API needs the Case Id (which only exists after insert), this shape does not apply.
Use Queueable with Database.AllowsCallouts. When the work involves complex logic, error handling, or chained calls, a queueable provides more structure than a future method.
public class CaseSuggestionJob implements Queueable, Database.AllowsCallouts {
private Id caseId;
private String description;
public CaseSuggestionJob(Id caseId, String description) {
this.caseId = caseId;
this.description = description;
}
public void execute(QueueableContext qc) {
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Knowledge_API/v1/suggest');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, String>{'text' => description}));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
update new Case(Id = caseId, Suggested_Articles__c = String.valueOf(body.get('articleIds')));
}
} catch (Exception e) {
update new Case(Id = caseId, Suggestion_Error__c = e.getMessage().left(255));
}
}
}
Queueables can chain (enqueue follow-up jobs), accept complex parameters, and pass the QueueableContext for tracking. For anything beyond a single fire-and-forget call, queueables are usually the better choice over future methods.
The fixed example
A controller, a queueable, and an async-aware design:
public class CaseSuggestionController {
@AuraEnabled
public static Id createCase(String subject, String description) {
Case c = new Case(
Subject = subject,
Description = description,
Status = 'New',
Suggestion_Status__c = 'Pending'
);
insert c;
System.enqueueJob(new CaseSuggestionJob(c.Id, description));
return c.Id;
}
}
public class CaseSuggestionJob implements Queueable, Database.AllowsCallouts {
private Id caseId;
private String description;
public CaseSuggestionJob(Id caseId, String description) {
this.caseId = caseId;
this.description = description;
}
public void execute(QueueableContext qc) {
Case toUpdate = new Case(Id = caseId);
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Knowledge_API/v1/suggest');
req.setMethod('POST');
req.setTimeout(60000);
req.setBody(JSON.serialize(new Map<String, String>{'text' => description}));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
toUpdate.Suggested_Articles__c = String.valueOf(body.get('articleIds'));
toUpdate.Suggestion_Status__c = 'Complete';
} else {
toUpdate.Suggestion_Status__c = 'Failed';
toUpdate.Suggestion_Error__c = 'HTTP ' + res.getStatusCode();
}
} catch (Exception e) {
toUpdate.Suggestion_Status__c = 'Failed';
toUpdate.Suggestion_Error__c = e.getMessage().left(255);
}
update toUpdate;
}
}
The controller commits the Case synchronously and returns the id. The queueable handles the callout, the response, and the status update. Users see the Case appear immediately and the suggestion status transition from Pending to Complete or Failed as the queueable finishes.
Edge cases and gotchas
Trigger context callouts. A trigger that performs DML and then attempts a callout hits this exception every time. The trigger context always has pending DML by the time the after-trigger handler runs. The right pattern is to enqueue a queueable from the trigger and let the queueable handle the callout.
Order matters even for setSavepoint. Calling Database.setSavepoint() does not commit the pending DML; it just marks a point. The DML is still pending. A callout after a savepoint still throws.
Test classes and mock callouts. Tests that exercise this exception are tricky because Test.startTest/Test.stopTest creates new governor scopes. Set up the HttpCalloutMock before the relevant DML and verify the exception fires precisely where expected. A unit test for the queueable shape does not hit this exception, which is exactly the point: the async refactor makes the constraint go away.
Mixed-DML during the same transaction. The platform also restricts setup objects (User, Group) from being modified in the same transaction as non-setup objects without DML breaks. That is a different exception (MIXED_DML_OPERATION) but the resolution pattern is the same: async hand-off.
Email sends. Messaging.sendEmail is treated like a callout for purposes of this restriction in some contexts. If your code does DML and then attempts to send email, the same separation rules apply. The platform may allow it in synchronous flows depending on the version, but the safest pattern is to send email from a queueable that runs after the DML commits.
Platform Events. Publishing a Platform Event from synchronous Apex is not a callout in this sense. Platform Events use a separate publication queue and do not contend with DML in the same way. They can be a useful fan-out point for callouts: insert the event, let a subscriber pick it up in a separate transaction and make the call.
Defensive habits
Default to async for any external API call. Synchronous callouts have a place (single API request that gates a user action), but most real-world callouts benefit from async semantics. Async lets you retry, log, observe, and resume without blocking the user.
Wrap every callout in try/catch. The exceptions you can hit go beyond CalloutException: network timeouts, JSON parsing errors, unauthorized responses, rate-limit responses. Each should produce a useful log entry on the originating record so support and engineering have visibility.
Track the async lifecycle on the record. A status field (Pending, In Progress, Complete, Failed) tells users what is happening. Without it, the user sees a Case in the UI with no indication that suggestions are being fetched and will surface as a separate field a moment later.
Use Named Credentials for every external endpoint. The Named Credential abstracts the URL, the authentication, and the network policy. Code that calls callout:Knowledge_API/... is portable across sandboxes and orgs because the credential travels with the metadata.
Test patterns
A test that verifies the synchronous flow commits without exception:
@IsTest
static void createCaseEnqueuesSuggestionJob() {
Test.setMock(HttpCalloutMock.class, new KnowledgeApiMock());
Test.startTest();
Id caseId = CaseSuggestionController.createCase('Need help', 'Cannot log in');
Test.stopTest();
Case reloaded = [SELECT Status, Suggestion_Status__c, Suggested_Articles__c FROM Case WHERE Id = :caseId];
System.assertEquals('Complete', reloaded.Suggestion_Status__c);
System.assertNotEquals(null, reloaded.Suggested_Articles__c);
}
Test.stopTest() flushes the queueable, the mock returns a canned response, and the assertions verify the end-to-end flow. The same test, run against the broken synchronous controller, would have caught the CalloutException at deploy time if it had existed before the refactor.
Diagnosing in production
When the exception fires:
- Identify the call site from the stack trace. The DML that committed before the callout is the offender.
- Decide whether the work belongs in async or whether the call order can be reversed.
- Refactor the callout into a queueable, future method, or batch.
- Add a status field on the parent record so users see the async lifecycle.
- Update tests to mock the HTTP response and assert on the post-async state.
Why this restriction exists at all
Apex transactions hold database locks while DML is pending. A callout that blocks the transaction for several seconds also holds those locks for several seconds. Other users hitting the same records have to wait. If the callout fails, the transaction rolls back, the locks release, and nothing has changed. If the callout succeeds, the transaction commits, the records take their new state, and the locks release. The platform's choice to forbid the pattern prevents the worst case where a callout hangs and locks accumulate across many transactions, gradually grinding the org to a halt.
The async pattern decouples the database work from the HTTP work. The synchronous transaction commits quickly. The async job runs separately, makes its callout against fresh state, and updates the record when the response arrives. Users see immediate feedback for the original action. The callout completes in the background without affecting other concurrent work.
Quick recovery checklist
- Move the callout to a queueable or future method.
- Insert any required parent record synchronously and pass the id to the async job.
- Update the async job's record on success or failure so the user sees the outcome.
- Test with HttpCalloutMock to confirm the new flow works end to end.
The refactor is mechanical once the pattern is understood. Most teams write the queueable in an afternoon and ship the fix on the next deploy.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Governor limit errors
Apex code is approaching a governor limit warning email
Governor limitSalesforce sent your team an email saying Apex is approaching a governor limit (typically 80% of CPU, SOQL, DML, or callout caps) but didn't…
STORAGE_LIMIT_EXCEEDED: storage limit exceeded
Governor limitYour org has hit its data storage or file storage cap. New record creation fails until you free space or buy more storage. Audit which objec…
System.LimitException: Apex CPU time limit exceeded
Governor limitYour transaction spent more than 10 seconds (sync) or 60 seconds (async) of CPU time inside Apex code. This counts compute, not waiting — SO…
System.LimitException: Apex heap size too large
Governor limitYour transaction is holding more data in memory at once than Apex allows: 6 MB synchronous, 12 MB asynchronous. Usually it's a `List<SObject…
System.LimitException: Maximum trigger depth exceeded
Governor limitTriggers can fire other triggers, which can fire more triggers — but only 16 levels deep. Hit 17 and the platform stops the whole transactio…