DEV Community

Maintask
Maintask

Posted on

Your Apex Might Return Fewer Records in Summer '26 — and Not Throw a Single Error

If you maintain custom Apex on a Salesforce org, Summer '26 (API v67.0) ships a change that can quietly alter what your code returns — no exception, no failed deploy, just different results. It's a good change. It's also the kind of change that surprises a production org if nobody saw it coming.

Here's what's happening and how to get ahead of it.

Problem: Apex used to ignore the running user's permissions

For most of Apex's history, database operations ran in system mode by default. That means SOQL, SOSL, DML, and Database methods ignored the running user's:

  • object permissions,
  • field-level security (FLS), and
  • sharing rules.

A query in a controller would happily return records the logged-in user was never supposed to see, and write to fields they couldn't access — unless you explicitly added guards. In a nonprofit org, that's exactly how a volunteer with a restricted profile ends up able to read every donor's giving history through a custom Lightning component nobody pen-tested.

The platform gave us tools to opt into security (WITH SECURITY_ENFORCED, Security.stripInaccessible(), with sharing on classes), but they were opt-in. If you forgot, your code ran wide open. Secure was the thing you had to remember; insecure was the default.

Solution: Summer '26 flips the default to user mode

In API version 67.0, that default inverts. Database operations now run in user mode unless you explicitly say otherwise. Concretely, on v67.0:

  1. SOQL / SOSL / DML / Database methods enforce the running user's object permissions, FLS, and sharing rules by default.
  2. A class with no sharing declaration now defaults to with sharing (previously an omitted declaration effectively inherited the caller's mode — often without sharing).
  3. WITH SECURITY_ENFORCED is removed and no longer compiles — you migrate to WITH USER_MODE.
  4. Triggers always run in system mode and can't declare a sharing or access mode.

The important nuance: this is keyed to the API version of the Apex class. Existing classes stay on their current API version and behave as before until you bump them to 67.0. So this is a migration you control — not an overnight change forced on every line of code you own. New classes you create on 67.0 get the new behavior immediately.

You opt back into system mode explicitly, per operation, when you genuinely need it:

// SOQL — inline
List<Opportunity> gifts = [SELECT Id, Amount FROM Opportunity WITH USER_MODE];   // enforce user access (now the default)
List<Opportunity> all   = [SELECT Id, Amount FROM Opportunity WITH SYSTEM_MODE]; // intentional escape hatch

// DML
insert as user newGifts;     // respect the user's create/FLS
update as system staleRows;  // intentional system-mode write

// Database methods — pass the AccessLevel enum
List<Opportunity> dyn = Database.query(
    'SELECT Id, Amount FROM Opportunity',
    AccessLevel.SYSTEM_MODE
);
Database.insert(newGifts, AccessLevel.USER_MODE);
Enter fullscreen mode Exit fullscreen mode

The mental model: user mode is the safe default; SYSTEM_MODE / without sharing is now the thing you write down on purpose, in the open, where a reviewer can see it.

Example: a donor-giving controller before and after

Say you have a Lightning component that shows a contact's giving history. Pre-v67, a common (and quietly unsafe) version looks like this:

// Pre-Summer '26 — no sharing declaration, system-mode query
public class DonorGivingController {
    @AuraEnabled(cacheable=true)
    public static List<Opportunity> getGifts(Id contactId) {
        // Runs in system mode: returns gifts regardless of who is asking,
        // and exposes Amount even if the user has no FLS on it.
        return [
            SELECT Id, Amount, CloseDate, StageName
            FROM Opportunity
            WHERE Primary_Contact__c = :contactId
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

On v67.0, the same class — with no code changes — now behaves as with sharing in user mode. If a restricted user opens the component:

  • gifts on records they can't see via sharing are filtered out, and
  • if they lack FLS on Amount, the query throws instead of leaking the field.

That's the behavior you almost certainly wanted all along. The explicit, reviewer-friendly version makes the intent obvious:

// Summer '26 — intent is explicit and secure by default
public with sharing class DonorGivingController {
    @AuraEnabled(cacheable=true)
    public static List<Opportunity> getGifts(Id contactId) {
        return [
            SELECT Id, Amount, CloseDate, StageName
            FROM Opportunity
            WHERE Primary_Contact__c = :contactId
            WITH USER_MODE
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

And when you do have a legitimate system job — a nightly rollup that must see every gift regardless of the running user — you say so, out loud:

public without sharing class NightlyGivingRollup implements Database.Batchable<SObject> {
    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator(
            'SELECT Id, Amount, Primary_Contact__c FROM Opportunity',
            AccessLevel.SYSTEM_MODE
        );
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Pitfalls: what actually breaks, and how to catch it

The reason this deserves a real migration pass — not just a version bump — is that the failures are often silent. Here are the ones to watch.

1. Queries return fewer rows, with no error. A query that returned 500 records can return 40 once user access is enforced, and nothing throws. Downstream logic that assumed a full result set produces wrong numbers — bad rollups, missing list items, under-counted reports. To confirm a row drop is access-related, compare the two modes:

Integer asUser   = [SELECT COUNT() FROM Opportunity WITH USER_MODE];
Integer asSystem = [SELECT COUNT() FROM Opportunity WITH SYSTEM_MODE];
System.debug('user=' + asUser + '  system=' + asSystem);
Enter fullscreen mode Exit fullscreen mode

A large gap means the running user simply can't see those rows — decide whether that's correct (it usually is) or whether this code is a true system context that needs SYSTEM_MODE.

2. WITH SECURITY_ENFORCED won't compile. If you bump a class to 67.0 and it still contains the old clause, deployment fails. Find-and-replace it with WITH USER_MODE, which is stricter (it also enforces sharing, not just FLS/object perms), so re-test afterward:

// Before (fails to compile on v67.0)
[SELECT Id, Name FROM Contact WITH SECURITY_ENFORCED];
// After
[SELECT Id, Name FROM Contact WITH USER_MODE];
Enter fullscreen mode Exit fullscreen mode

3. Integration and automation users hit walls. This is the big one for nonprofits with connected apps (Click & Pledge, FormAssembly, ETL/data loads, scheduled jobs). Code that runs as an integration user and assumed system-level reach will now be filtered to that user's permissions. The fix is a deliberate choice per context: either grant the integration user the object/field/sharing access it legitimately needs, or mark the genuinely-system code paths without sharing + SYSTEM_MODE. Don't blanket-SYSTEM_MODE everything to make red tests green — that just recreates the old wide-open default you're trying to leave behind.

4. The sharing-declaration default flips. A class you relied on to run unrestricted (because an omitted declaration used to behave like without sharing in many call paths) may now enforce sharing as with sharing. Audit every class with no explicit declaration and write the intent down — with sharing or without sharing — so behavior no longer depends on who called it.

5. Triggers no longer enforce sharing. All triggers now run in system mode, period. If any logic quietly leaned on sharing being enforced inside a trigger, move it into a handler class that declares with sharing:

trigger OpportunityTrigger on Opportunity (after update) {
    new OpportunityTriggerHandler().afterUpdate(Trigger.new, Trigger.oldMap);
}

public with sharing class OpportunityTriggerHandler {
    public void afterUpdate(List<Opportunity> updated, Map<Id, Opportunity> oldMap) {
        // sharing is enforced here because the class declares it
    }
}
Enter fullscreen mode Exit fullscreen mode

A pragmatic migration order

You don't have to do this all at once — the API version is per-class, so migrate deliberately:

  1. Inventory first. Search your codebase for WITH SECURITY_ENFORCED (must change) and for classes with no sharing declaration (behavior may change).
  2. Bump high-risk classes in a sandbox, one batch at a time: controllers, @AuraEnabled methods, REST endpoints, and anything an integration user runs.
  3. Run your tests, but don't trust green alone — user-mode bugs are about data visibility, which weak test setups miss. Add tests that run as a restricted user via System.runAs().
  4. Decide each escape hatch on purpose. Every SYSTEM_MODE / without sharing should be a conscious, commented decision, not a reflex to silence a failure.
  5. Only then bump the API version in production, class group by class group.

Done this way, a change that could have been a silent production incident becomes a routine, secure-by-default upgrade — which is exactly what it's meant to be.

Further reading

If you want the rest of the Summer '26 picture — key dates, the enforced updates, Flow and reporting changes, and a release checklist — we put together a full breakdown here: Salesforce Summer '26 Release: Key Dates, Features, Flow Updates & Release Checklist.

Keep focusing on your priorities. We take care of your CRM.

Top comments (0)