Skip to main content

SF-0358 · Scenario · Hard

How can we avoid the above exception?

✓ Verified by Vikas Singhal · Last reviewed 5/17/2026 · Updated for Spring '26

The fix is to detect the async context and skip the future call when you’re already inside a batch (or another future). Apex exposes two static helpers for exactly this: System.isBatch() and System.isFuture().

The guard pattern

trigger OpportunityTrigger on Opportunity (after update) {
    // Skip async work if we're already inside an async transaction
    if (System.isBatch() || System.isFuture() || System.isQueueable()) return;

    Set<Id> ids = new Set<Id>();
    for (Opportunity o : Trigger.new) ids.add(o.Id);
    OpptyAsync.callExternal(new List<Id>(ids));
}

Now when the batch updates opportunities, the trigger fires, sees System.isBatch() == true, and silently skips the future call. The batch’s execute continues, the chunk commits, no exception.

The available context helpers

MethodReturns true when…
System.isBatch()Running inside Batch Apex execute or finish
System.isFuture()Running inside an @future method
System.isQueueable()Running inside a Queueable execute
System.isScheduled()Running inside a Schedulable execute

Calling out from System.isScheduled() is allowed (Scheduled apex can enqueue futures), but you usually still want to guard against it for predictability.

Where to put the guard

Two reasonable strategies:

1. Guard at the trigger entry

trigger OpportunityTrigger on Opportunity (after update) {
    if (System.isBatch() || System.isFuture()) return;
    // ... rest of handler
}

Simple, but skips all trigger logic — including any non-async work — when called from batch. That may not be desired.

2. Guard only the async call

trigger OpportunityTrigger on Opportunity (after update) {
    // sync work always runs
    updateRelatedRecords();

    // async work only if it's safe
    if (!System.isBatch() && !System.isFuture()) {
        OpptyAsync.callExternal(opptyIds);
    }
}

This is usually what you want — the trigger still updates the database, just doesn’t try to enqueue async work that would fail.

Better: use Queueable instead

If you really need to defer work from the trigger even when it fires from batch, switch to Queueable. Queueable can be enqueued from inside batch finish (not execute), or you can refactor the batch to call the Queueable itself:

public void finish(Database.BatchableContext ctx) {
    // Allowed: enqueue Queueable from finish
    System.enqueueJob(new PostBatchSync());
}

In modern code, the answer to “future from batch” is almost always “use Queueable.”

Don’t forget tests

@isTest
static void testTriggerSkipsFutureInBatch() {
    Test.startTest();
    Database.executeBatch(new OpptyUpdateBatch(), 200);
    Test.stopTest();
    // Assert: no exception, batch ran to completion
    AsyncApexJob job = [SELECT Status, NumberOfErrors FROM AsyncApexJob WHERE JobType = 'BatchApex' LIMIT 1];
    System.assertEquals(0, job.NumberOfErrors);
}

Common interview follow-ups

  • Does System.isBatch() work in the trigger called from batch? — Yes — that’s exactly its purpose.
  • Why not just try/catch? — You can, but it’s a runtime guard, you still pay the cost, and the catch is harder to read than a clear context check.
  • What about Limits.getFutureCalls()? — That returns the count of future calls already enqueued; useful for capacity guards, not for context detection.

Verified against: Apex Developer Guide — System Class. Last reviewed 2026-05-17.