Skip to main content

SF-0350 · Concept · Medium

Why does the future method not support sObject data types as arguments?

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

Salesforce blocks sObject arguments on @future methods for one reason: the future method runs later, in a separate transaction, after the caller’s DML has committed. An sObject passed at queue time would be a frozen snapshot — field values from before any commit. By the time the future runs, those values may be wrong. Forcing you to pass an Id and re-query inside the method guarantees you see the current database state.

The timeline that explains it

T0  trigger fires; record has Status__c = 'New'
T0  trigger calls @future, "passing" the record
T0  trigger commits; another trigger updates Status__c to 'Approved'
T1  @future runs (seconds later)
T1  if sObject were allowed, you'd see 'New' — wrong!
T1  with re-query, you see 'Approved' — correct

A snapshot passed at T0 wouldn’t reflect the workflow field updates, validation re-runs, or post-commit logic that happened between T0 and T1. The platform avoids that footgun by simply disallowing sObject parameters.

What you can pass

@future parameters are restricted to:

  • PrimitivesBoolean, Integer, Long, Decimal, Double, Date, Datetime, Time, String
  • Id (technically a primitive in Apex)
  • Collections of primitivesSet<Id>, List<Id>, List<String>, Map<String, String>, etc.

You cannot pass:

  • A single sObject (Account, Opportunity, etc.)
  • A List<sObject> or Map<Id, sObject>
  • A custom Apex class (even one with only primitive fields)

The standard workaround

Pass the Set<Id> of records and re-query at the top of the method:

trigger AccountTrigger on Account (after update) {
    Set<Id> changedIds = new Set<Id>();
    for (Account a : Trigger.new) {
        if (a.Status__c != Trigger.oldMap.get(a.Id).Status__c) {
            changedIds.add(a.Id);
        }
    }
    if (!changedIds.isEmpty()) AccountSyncService.syncToErp(changedIds);
}

public class AccountSyncService {
    @future(callout=true)
    public static void syncToErp(Set<Id> ids) {
        // Re-query — we get the current, committed state
        List<Account> accs = [
            SELECT Id, Name, Status__c, Industry FROM Account WHERE Id IN :ids
        ];
        for (Account a : accs) {
            ErpClient.upsert(a);
        }
    }
}

The re-query is not waste — it’s correctness.

Queueable doesn’t have this restriction

Queueable accepts complex types — sObjects, custom classes, anything you can serialize. That doesn’t mean Queueable is immune to staleness; the same timing problem exists. The platform just trusts you to handle it.

Best practice with Queueable is still “pass Ids, re-query” for the same reason: the data may have changed between enqueue time and execute time.

Common interview follow-ups

  • Can I pass a custom Apex class to @future? — No. Only primitives, Ids, and collections of primitives. A class with only primitive fields is still rejected.
  • Can I pass JSON-serialized record data as a string? — Technically yes — pass a String, deserialize inside. But you’ve just rebuilt the staleness problem manually.
  • Why does Queueable allow sObjects? — Different design. Queueable is meant for richer workflows; Salesforce assumes you understand the staleness trade-off.

Verified against: Apex Developer Guide — Future Methods. Last reviewed 2026-05-17.