---
title: "You have uncommitted work pending: callout ordering in Apex"
url: "https://trailassembly.com/uncommitted-work-pending/"
topic: "Apex and Development"
published: "2026-09-09T06:42:27+00:00"
updated: "2026-10-06T13:51:07+00:00"
error_codes: "CalloutException, DmlException, IllegalArgumentException"
---

# You have uncommitted work pending: callout ordering in Apex

**Short answer:** your transaction issued a DML statement and then tried to make an HTTP callout. Salesforce refuses, because it will not hold a database transaction open while your code waits on a server it does not control. Either move the callout *before* the DML, or move the callout into an asynchronous context — `@future(callout=true)` or a [Queueable](https://trailassembly.com/apex-choosing-async/) that implements `Database.AllowsCallouts`. There is no third option that keeps both in the same transaction.

## The exact error, and the four things that cause it

The message is a `System.CalloutException`:

```apex
System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
```

Salesforce describes this as “a multi-tenant related platform limitation” in its own knowledge article on the subject. The Apex Developer Guide is blunter: “You can’t make a callout when there are pending operations in the same transaction.” Pending operations are not just DML. According to the callout limits documentation, they include:

- DML statements — `insert`, `update`, `upsert`, `delete`, `Database.*` methods

- Asynchronous Apex you enqueued, including `System.enqueueJob()` and `@future` calls

- Scheduled Apex you scheduled with `System.schedule()`

- Email you sent with `Messaging.sendEmail()`

The same page confirms the inverse is fine: “You can make callouts before performing these types of operations.” That single sentence is the whole fix, and it is why the ordering matters more than the code.

Note which exception type you get. It is a `CalloutException`, not a `DmlException`, and it fires at the callout line — potentially several classes away from the DML that caused it. If you are staring at an `Http.send()` call trying to work out what is wrong with your endpoint, you are looking in the wrong place entirely.

## “Commit or rollback” is misleading advice — Apex has no commit

The error message reads like it is telling you to do something. It isn’t, really. Apex gives you no `commit` statement. The transaction commits when it completes successfully and not before. The only rollback available is `Database.rollback()` against a savepoint you set earlier, and that discards the DML you just did, which is presumably not what you wanted.

```apex
Savepoint sp = Database.setSavepoint();
insert newAccount;
Database.rollback(sp);        // account insert is now gone
HttpResponse res = h.send(req);   // this works, and is pointless
```

So treat the message as diagnostic rather than instructional. It is telling you the ordering is wrong, not offering you a way out.

## Any callout made synchronously from a trigger will fail, whatever your code does

This is the part that catches people, and it is worth being precise about. An `after insert` or `after update` trigger is running inside the save that fired it. That save is itself uncommitted DML. Your trigger body does not need to contain a single DML statement — the DML is the record operation you are reacting to, and it is upstream of anything you write.

```apex
trigger AccountTrigger on Account (after insert) {
    // No DML anywhere in this trigger. It still throws.
    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:Billing_API/customers');
    req.setMethod('POST');
    new Http().send(req);
}
```

The practical rule: callouts from triggers are always asynchronous. Not “usually”, not “unless the trigger is simple”. Always. The same reasoning applies to any Apex invoked from a record-triggered flow after the save, to invocable Apex called from an [after-save flow](https://trailassembly.com/before-save-vs-after-save/), and to anything running in a validation-and-save path.

There is a second, quieter version of this in service classes. A method that calls out is perfectly correct in isolation and starts throwing the moment somebody calls it from a controller that already saved a record three lines earlier. The callout code did not change. Its caller did. If you own a class that makes callouts, document the constraint at the top of the file, because nothing in the method signature communicates it.

## Fix 1: reorder. Call out first, then write

The cheapest fix, and the one to reach for whenever the DML does not depend on the callout’s outcome being already saved. Get the external data, then persist everything in one pass at the end.

```apex
public static void syncCustomer(Id accountId) {
    Account a = [SELECT Id, Name, BillingCity FROM Account WHERE Id = :accountId];

    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:Billing_API/customers/' + accountId);
    req.setMethod('GET');
    HttpResponse res = new Http().send(req);   // callout first

    a.External_Status__c = parseStatus(res.getBody());
    update a;                                  // DML last
}
```

Two things to be careful about. First, if the callout fails you have written nothing, which is usually the behaviour you want but should be a deliberate choice rather than an accident. Second, this pattern collapses the moment anything upstream in the same transaction writes to the database, so it only survives in genuinely synchronous entry points: a controller action, an `@AuraEnabled` method, an executeAnonymous script.

## Fix 2: move the callout async. The fix that actually holds up

For triggers and for anything where the DML must come first, push the callout out of the transaction. Two mechanisms, and they are not equivalent.

**Queueable** is what I would use for new code. Salesforce says so too. The asynchronous Apex overview states plainly that “Salesforce recommends that you use Queueable Apex instead of Apex future methods.” You get a job Id back, you can pass non-primitive types, and you can chain a follow-up job.

```apex
public class CustomerSyncJob implements Queueable, Database.AllowsCallouts {
    private Set<Id> accountIds;

    public CustomerSyncJob(Set<Id> accountIds) {
        this.accountIds = accountIds;
    }

    public void execute(QueueableContext ctx) {
        List<Account> toUpdate = new List<Account>();

        for (Account a : [SELECT Id, Name FROM Account WHERE Id IN :accountIds]) {
            HttpRequest req = new HttpRequest();
            req.setEndpoint('callout:Billing_API/customers/' + a.Id);
            req.setMethod('GET');
            HttpResponse res = new Http().send(req);

            a.External_Status__c = parseStatus(res.getBody());
            toUpdate.add(a);
        }

        update toUpdate;
    }
}
```

Enqueue it from the [trigger handler](https://trailassembly.com/apex-trigger-frameworks/) once, with the whole set of Ids:

```apex
if (!System.isBatch() && !System.isFuture()) {
    System.enqueueJob(new CustomerSyncJob(Trigger.newMap.keySet()));
}
```

The guard matters. You can enqueue up to 50 jobs per synchronous transaction, but from inside an asynchronous transaction you can add only one: the Queueable documentation is explicit about that asymmetry, and hitting it produces a different exception that reads nothing like this one.

**Future methods** still work and are shorter to write, at a real cost: parameters must be primitives, arrays of primitives, or collections of primitives, and the return type must be `void`. So you pass Ids and requery.

```apex
@future(callout=true)
public static void syncCustomersAsync(Set<Id> accountIds) {
    // requery — you cannot pass sObjects in
}
```

Also note that a future method cannot call another future method: the [governor limits](https://trailassembly.com/governor-limits-reference/) table lists the maximum number of future calls as 0 in batch and future contexts. Queueable does not have that dead end, which is the main reason I stopped writing new future methods.

## Fix 3: publish a platform event instead of writing directly

Worth knowing because it inverts the problem. Salesforce’s knowledge article on this limitation notes that “Salesforce will ALLOW you to publish a platform event before a callout.” So if the DML blocking your callout is really a notification to other automation rather than data you need saved right now, publish an event, call out, and let a subscriber do the writing.

The governor budget for this is 150 `EventBus.publish` calls per transaction. The same order of magnitude as DML statements, so it is not a way to escape volume limits. It is a way to change what your transaction is responsible for.

## The tradeoff nobody mentions: you have just chosen eventual consistency

Going async fixes the exception. It also changes the guarantees your feature offers, and you should say that out loud before you ship it.

Once the callout runs in a separate transaction, the record is saved and visible to users before the external system knows about it. There is a window (usually seconds, occasionally much longer when the async queue is backed up) where your org and the remote system disagree. If the callout fails, the record stays saved and wrong, and nothing rolls back for you.

That means async callouts need three things synchronous ones don’t: a field on the record recording sync state, retry handling that does not retry forever, and something a human can look at to find records that never made it. A `Queueable` that swallows its own exception is worse than the original error, because the original error was at least visible.

If your requirement genuinely cannot tolerate that window, the user must not see a saved record until the remote system has accepted it, then async is the wrong answer and you need to restructure the entry point so the callout happens first, before anything is written. That is a design conversation, not a code fix.

## Catching it before production does

You can check for pending work at runtime and fail with a message that says something useful:

```apex
if (Limits.getDmlStatements() > 0) {
    throw new IllegalArgumentException(
        'BillingService.sync must run before any DML in this transaction'
    );
}
```

That turns an inscrutable `CalloutException` in a log somebody reads next week into a named failure at the point of misuse. It costs one line.

## Frequently asked

### Why does the platform care? The DML already happened.

It happened but it has not committed. Holding the transaction open across a network call to a system Salesforce does not control would mean holding row locks for an unbounded period on shared infrastructure. Salesforce classifies this as a multi-tenancy limitation, and it is not configurable.

### Does a SOQL query before the callout cause this?

No. Reads are fine in any order and any quantity. Only the pending operations listed in the callout documentation block a callout: DML, enqueued async Apex, scheduled Apex, and sent email.

### Can I get the exception from a batch job?

Yes. Each `execute()` method is its own transaction, so DML then a callout inside the same `execute()` fails exactly like it would anywhere else. Being asynchronous already does not exempt you. It only means you are allowed to make callouts at all, provided the class implements `Database.AllowsCallouts`.

### Will setting a savepoint let me call out?

No. A savepoint marks a point you can roll back to. It does not commit anything. Only an actual `Database.rollback()` clears the pending work, and it clears your data along with it.

### What about a callout from a flow?

Same constraint, same reasoning. An HTTP callout action in a record-triggered flow configured to run after save is inside the uncommitted save, so it fails. Run the callout from a flow triggered on a platform event, or from asynchronous Apex the flow invokes.

### Does going async use up more of my limits?

It uses a different pool. Each queued job counts once against the org’s shared 24-hour asynchronous Apex execution limit, and the async transaction gets its own budget. 200 SOQL queries and 12 MB of heap rather than 100 and 6 MB. The details are covered in *Synchronous vs asynchronous [governor limits](https://trailassembly.com/governor-limits-reference/), side by side*.

---

*Reference: [Callout Limits and Limitations](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_callouts_timeouts.htm) and [Apex Governor Limits](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm), Salesforce Apex Developer Guide; [Standard Salesforce Limitation DML before Apex Callouts](https://help.salesforce.com/s/articleView?id=000394416&language=en_US&type=1), Salesforce Help.*

---

Source: https://trailassembly.com/uncommitted-work-pending/
Trail Assembly — independent Salesforce write-ups.
