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:
- Primitives —
Boolean,Integer,Long,Decimal,Double,Date,Datetime,Time,String - Id (technically a primitive in Apex)
- Collections of primitives —
Set<Id>,List<Id>,List<String>,Map<String, String>, etc.
You cannot pass:
- A single sObject (
Account,Opportunity, etc.) - A
List<sObject>orMap<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.