[object Object]

The Behavior That Surprises Developers

In Apex, two SObject instances are considered equal when their field values match — not when they refer to the same memory. That’s documented. What’s not well-documented is the four cases where this comparison silently produces a “not equal” result on data you’d swear is identical, and the impact lands hardest on Map<SObject, X> and Set<SObject> operations where lookups suddenly miss.

If you’ve ever written if (myMap.containsKey(acct)) {...} and watched it skip records that should obviously match, this is the bug.

Case 1: One Side Was Loaded from a Query, the Other Was New

A queried SObject carries every field the query returned plus tracking metadata for which fields were explicitly set. A new SObject carries only the fields you explicitly assigned. When Apex compares them, it walks both field sets. If the queried record has LastModifiedDate populated and the constructed one doesn’t, the records are not equal — even if every business field matches.

The fix is to either query identically on both sides or normalize to a key class. The pattern that holds up:

// Don't:
Map<Account, Decimal> totals = new Map<Account, Decimal>();
Account a = [SELECT Id, Name FROM Account LIMIT 1];
totals.put(a, 100);
totals.get(new Account(Id=a.Id, Name=a.Name)); // returns null

// Do:
Map<Id, Decimal> totals = new Map<Id, Decimal>();
totals.put(a.Id, 100);
totals.get(a.Id); // returns 100

Use the Id when the Id exists. Use a typed wrapper class with equals() and hashCode() when no Id is yet available. Don’t put bare SObjects in Maps or Sets unless you’ve controlled the construction path on both sides.

Case 2: Trigger.new vs Trigger.newMap.values()

These look interchangeable. They’re not. Trigger.new is a list of mutable records — the records you can edit in before triggers. Trigger.newMap.values() is a list of clones produced by the Map’s value iterator in some scenarios in 2026 (this changed in the Summer ‘24 release).

If you keep references to Trigger.new[i] and Trigger.newMap.get(Trigger.new[i].Id) and later compare them, the comparison is true early in the trigger but may be false after a before mutation, because one side has been edited and the other holds a stale field-state snapshot.

The pattern that survives: pick one source — almost always Trigger.newMap for lookups, Trigger.new for iteration — and stick to it. Don’t mix.

Case 3: Null Equality on Reference Fields

Account.OwnerId == null and Account.Owner == null evaluate independently. Two Accounts where both have OwnerId = null but one was constructed with Owner = null set explicitly and the other was queried without selecting Owner can compare unequal because one has the field “present-and-null” and the other has it “absent.”

This is the case that produces “intermittent” failures in tests: works locally, fails in CI. The CI org seeded data differently and the field presence differs.

The diagnostic: in your system.assertEquals(a, b) failure, log a.getPopulatedFieldsAsMap().keySet() and b.getPopulatedFieldsAsMap().keySet(). If the sets differ, you found the bug.

Case 4: Polymorphic Lookups (Who, What)

Task.WhoId can point to a Contact or a Lead. Task.WhatId can point to half a dozen objects. When you put Tasks in a Map keyed by Task, two Tasks with the same Subject but WhoId pointing to a Contact in one and a Lead in the other don’t compare equal because the polymorphic reference resolution differs even when the underlying Id matches.

This is rare in practice but appears in deduplication scripts that operate across Lead and Contact populations. If you’re writing such a script, key your Map on String.valueOf(t.WhoId) instead of on the Task itself.

A Test That Catches All Four

If you maintain a test data factory, add a single unit test that does this:

@isTest
static void testSObjectEqualityContract() {
    // Two paths to "the same record"
    Account a = new Account(Name='X');
    insert a;
    Account queried = [SELECT Id, Name FROM Account WHERE Id = :a.Id];
    Account constructed = new Account(Id=a.Id, Name='X');
    
    Map<Account, Integer> m = new Map<Account, Integer>();
    m.put(queried, 1);
    
    // This should be 1 if your code assumes SObject equality "just works"
    System.assertEquals(1, m.get(constructed), 'SObject map keying is unreliable');
}

That test reliably fails. Once you’ve seen it fail, you’ll catch the pattern in code review the rest of your career.

What to Do This Week

Grep your codebase for Map<Account,, Map<Contact,, Map<Opportunity,, and Set<SObject>. Replace each with Map<Id, ...> or a typed key class. Re-run tests. The two or three tests that now flap on null populated-field-set differences are bugs you couldn’t see before.

[object Object]
Share