Apex runtime error

System.AsyncException: Future method cannot be called from a future or batch method

"Future method cannot be called from a future or batch method" — what it means and how to fix it

Apex refuses to queue an @future call from a transaction that is already running as a future or as Batch Apex. Most of the time nobody wrote that call chain deliberately: a trigger calls @future, and one day the DML firing that trigger comes from a batch.

Quick answer

Either guard the call — if (!System.isFuture() && !System.isBatch()) — and run the work synchronously in the async path, or replace @future with a Queueable, which the platform permits from contexts where @future is blocked and which supports chaining. The guard is the smaller change; the Queueable is usually the right one.

What the error means, mechanically

@future exists to move work into a separate asynchronous transaction. The platform enforces a hard rule about where that transaction may be started from: an @future method cannot be invoked while the current transaction is itself a future execution or a Batch Apex execution. Attempting it throws System.AsyncException at the call site, and the message names the exact method it refused to queue:

text
System.AsyncException: Future method cannot be called from a future or batch method:
  AccountSyncService.pushToErp(Set<Id>)

The restriction is about unbounded fan-out. A future that can queue another future can queue an unbounded chain of them, with no chaining semantics and no depth limit to reason about. Batch Apex has the same problem in a different shape: one batch of 10,000 records could queue 200 futures per chunk. Rather than police it, the platform forbids the edge.

Two things worth being precise about, because both get misremembered:

  • The message names exactly two contexts — future and batch. A Queueable or a Schedulable is not what this exception is about; that's why "rewrite it as a Queueable" is the standard escape hatch rather than a workaround.
  • The call site is what matters, not the class. The same @future method is perfectly legal from a synchronous transaction and illegal from a batch, in the same org, on the same day. Nothing about the method's declaration changes.

The causes, ranked by how often they actually happen

1. A trigger calling @future, fired by DML inside a batch

The overwhelming majority of real occurrences, and the reason this error tends to appear long after the code was written. The trigger is fine. The @future is fine. Then someone writes a data-fix batch that updates the same object, the trigger runs inside execute(), and the whole batch fails.

apex
// The trigger nobody thought about
trigger AccountTrigger on Account (after update) {
  AccountSyncService.pushToErp(Trigger.newMap.keySet());
}

public class AccountSyncService {
  @future(callout=true)
  public static void pushToErp(Set<Id> accountIds) { /* callout */ }
}

// Six months later — every chunk throws AsyncException
public class AccountBackfillBatch implements Database.Batchable<SObject> {
  public void execute(Database.BatchableContext bc, List<Account> scope) {
    for (Account a : scope) { a.Region__c = 'EMEA'; }
    update scope;   // fires AccountTrigger -> @future -> AsyncException
  }
}

The minimal fix is a guard at the boundary, with a synchronous fallback so the work still happens:

apex
public class AccountSyncService {
  public static void sync(Set<Id> accountIds) {
    if (System.isFuture() || System.isBatch()) {
      // Already async — do the work in this transaction.
      // Note: callouts are not allowed from a trigger inside a batch,
      // so this path must be callout-free, or the work must be recorded
      // for a later job to pick up.
      recordForLaterSync(accountIds);
    } else {
      pushToErp(accountIds);
    }
  }

  @future(callout=true)
  private static void pushToErp(Set<Id> accountIds) { /* callout */ }
}

That comment matters: the guard stops the exception, it does not make a callout legal in batch context. If the async work exists because of a callout, the guard alone isn't a fix — go to the Queueable rewrite below.

2. Future calling future, usually through a helper

Rarely written directly. It appears when a shared service method is called from both synchronous code and an @future, and somewhere down the call chain it queues another @future. The stack that produces it can be four classes deep, which is why the fix belongs in the service, not at the top-level call site.

3. Batch Apex calling @future directly

Typically to make a callout per chunk. This is the case the Queueable rewrite solves cleanly, since a batch may enqueue a Queueable job.

4. The test that only fails at Test.stopTest()

Not a separate cause so much as a separate symptom, and it wastes real time. Async work queued inside Test.startTest()/Test.stopTest() does not run when you queue it — it runs at stopTest(). So a batch enqueued in the test executes at stopTest(), its DML fires the trigger there, and the AsyncException is reported on the Test.stopTest() line rather than anywhere near the code that caused it.

apex
@isTest
static void testBackfill() {
  Test.startTest();
  Database.executeBatch(new AccountBackfillBatch(), 200);
  Test.stopTest();   // <- AsyncException surfaces here, not above
}

Read the exception message, not the line number: it names the @future method that was refused, which points straight at the real call site.

The Queueable rewrite

When the async work is genuinely needed from batch or future context, converting to Queueable is the durable fix rather than a dodge. It's allowed where @future is blocked, it takes real objects instead of only primitives, it supports Database.AllowsCallouts, and it can chain.

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

  public ErpSyncJob(Set<Id> accountIds) {
    this.accountIds = accountIds;   // typed state, not just primitives
  }

  public void execute(QueueableContext ctx) {
    // callout + DML here
  }
}

// Legal from a trigger running inside a batch
System.enqueueJob(new ErpSyncJob(Trigger.newMap.keySet()));

Two constraints to keep in view: only one job may be enqueued per Queueable execution (so chains are linear, not trees), and the number of jobs you may enqueue from a single transaction is limited — a trigger that enqueues per record instead of per batch will hit that limit. Collect the Ids, enqueue once.

Detecting the context you're in

apex
System.debug('isFuture: '    + System.isFuture());
System.debug('isBatch: '     + System.isBatch());
System.debug('isQueueable: ' + System.isQueueable());
System.debug('isScheduled: ' + System.isScheduled());

// Future calls already used in this transaction, and the ceiling
System.debug(Limits.getFutureCalls() + ' / ' + Limits.getLimitFutureCalls());

Limits.getFutureCalls() is worth watching alongside this error. Code that queues a future per record rather than per batch will trip the future-call limit as soon as the guard stops it tripping AsyncException — same design flaw, second symptom.

Where Nimbus helps here — and where it doesn't

Honest version first: Nimbus does not raise this exception. Its local runtime queues @future calls in test context and runs them at Test.stopTest(), and an @future call made from inside a running async job executes inline rather than being refused. It also reports System.isFuture() as false. So a green local run does not prove this call chain is legal in the org — if you are fixing an AsyncException, verify the guard against a real org before you trust it.

What is genuinely useful locally is everything around the exception: the async lifecycle runs on your machine, so batch start/execute/finish, queued jobs at Test.stopTest(), trigger re-entry from batch DML, and Limits.getFutureCalls() accounting all execute without a deploy. That's the part of an async rewrite that normally costs the most iterations — restructuring the job graph and re-running the suite until the sequencing is right.

bash
# Iterate on the async rewrite locally
nimbus test "AccountBackfillBatchTest.*"

# See the operations that ran, in order, around the failure
nimbus explain AccountBackfillBatchTest.testBackfill