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
| Method | Returns 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.