DEV Community

stackOverflowed
stackOverflowed

Posted on

Debugging Salesforce Apex - 8 Gotchas That Waste Everyone's Weekend

If you've ever stared at a Salesforce debug log wondering why a trigger fired twice, why your query "worked yesterday," or why a batch job silently ate an exception — this one's for you.

Apex debugging is different from debugging most languages because you're not just fighting your own code. You're fighting governor limits, an opaque order of execution, and a debug log format that seems designed to hide the one line you need. Here are the gotchas that trip people up most, with real code.

1. Your catch block is swallowing the actual error

This is the single most common reason "nothing shows up in the logs":

try {
    insert accounts;
} catch (Exception e) {
    System.debug('Something went wrong');
}
Enter fullscreen mode Exit fullscreen mode

You caught the exception, printed a string with zero context, and moved on. When it breaks in production, you have nothing to go on.

Fix:

try {
    insert accounts;
} catch (DmlException e) {
    for (Integer i = 0; i < e.getNumDml(); i++) {
        System.debug(LoggingLevel.ERROR,
            'DML failed on row ' + e.getDmlIndex(i) +
            ': ' + e.getDmlMessage(i));
    }
    throw e; // don't swallow it unless you have a real reason to
}
Enter fullscreen mode Exit fullscreen mode

Catch specific exception types (DmlException, QueryException, NullPointerException) instead of generic Exception whenever you can — the specific types give you methods like getDmlMessage() and getDmlIndex() that a bare Exception doesn't.

2. Debug logs get truncated at 2MB — and truncate from the middle

Salesforce debug logs have a hard 20MB size cap per transaction, but the piece you actually read in Setup often gets cut around 2MB depending on log level. When a log is huge, Salesforce removes lines from the middle, not the end — so you'll see the start of your transaction and the finish, with the actual bug missing.

Fix: Don't set every category to FINEST. Set only what you need:

APEX_CODE: DEBUG (not FINEST)
DB: INFO
WORKFLOW: INFO
VALIDATION: INFO
CALLOUT: INFO
Enter fullscreen mode Exit fullscreen mode

FINEST on APEX_CODE logs every variable assignment and method entry/exit — it's the fastest way to blow past the size limit and lose the exact line you care about.

3. Governor limits don't show up until they blow up

Apex is multi-tenant, so you get hard caps: 100 SOQL queries, 150 DML statements, 10,000 SOQL rows, 10 seconds of synchronous CPU time. The gotcha isn't that limits exist — it's that code that looks fine in isolation fails the moment it runs in bulk (more on that in #4).

Instrument before you hit the wall:

System.debug('SOQL queries used: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('DML used: ' + Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());
System.debug('CPU time: ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
Enter fullscreen mode Exit fullscreen mode

Drop these at the top and bottom of suspect methods. When someone says "it works for one record but times out on a bulk import," this is how you find out where.

4. SOQL/DML inside a for loop — the classic that still gets everyone

// DO NOT DO THIS
for (Contact c : triggerNew) {
    Account a = [SELECT Id, Name FROM Account WHERE Id = :c.AccountId]; // query in a loop
    a.Last_Contact_Date__c = Date.today();
    update a; // DML in a loop
}
Enter fullscreen mode Exit fullscreen mode

This passes code review constantly because the person testing it only inserted one record. Insert 200 Contacts via a Data Loader batch and you'll blow past the 100-query or 150-DML limit instantly.

Fix — bulkify it:

Set<Id> accountIds = new Set<Id>();
for (Contact c : triggerNew) {
    if (c.AccountId != null) accountIds.add(c.AccountId);
}

Map<Id, Account> accountsToUpdate = new Map<Id, Account>(
    [SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);

for (Contact c : triggerNew) {
    if (accountsToUpdate.containsKey(c.AccountId)) {
        accountsToUpdate.get(c.AccountId).Last_Contact_Date__c = Date.today();
    }
}

update accountsToUpdate.values();
Enter fullscreen mode Exit fullscreen mode

One query, one DML statement, regardless of batch size.

5. Test.startTest() / Test.stopTest() reset limits — this can hide real bugs

@isTest
static void testBulkInsert() {
    List<Account> accs = TestDataFactory.createAccounts(200);
    Test.startTest();
    insert accs;
    Test.stopTest();
    // asserts...
}
Enter fullscreen mode Exit fullscreen mode

Test.startTest() gives you a fresh set of governor limits and forces async work (@future, queueables, batch chunks) to run synchronously inside stopTest(). That's great for testing async logic — but it also means limit-related bugs that would show up in a single real transaction can hide inside your test because the limits reset partway through. If a bug only appears in production and never in tests, check whether your DML/queries are split across the startTest/stopTest boundary in a way production traffic never gets.

6. Relationship field queries return null, not an exception — until you touch a field on them

Contact con = [SELECT Id, Account.Name, Account.Owner.Email FROM Contact WHERE Id = :conId];
System.debug(con.Account.Name); // fine if Account exists
System.debug(con.Account.Owner.Email); // NullPointerException if Account is null
Enter fullscreen mode Exit fullscreen mode

If AccountId is null on that Contact, con.Account itself is null — and reaching for .Owner on a null Account throws a NullPointerException with a stack trace that just says "line 47," giving you no hint about which relationship was null.

Fix: Guard each hop, or flatten with a null-safe pattern:

String ownerEmail = (con.Account != null && con.Account.Owner != null)
    ? con.Account.Owner.Email
    : null;
Enter fullscreen mode Exit fullscreen mode

7. without sharing vs with sharing causes silent missing data, not errors

public without sharing class DataProcessor {
    public List<Account> getAccounts() {
        return [SELECT Id, Name FROM Account]; // sees ALL records, ignores sharing rules
    }
}
Enter fullscreen mode Exit fullscreen mode

The dangerous version of this bug is the reverse: a class declared with sharing (or with no declaration, which inherits the caller's context) queries fewer records than expected for a particular user, and nothing throws an error — the query just returns 3 rows instead of 300. This is brutal to debug because it looks like a data problem, not a code problem.

Fix: When you get a "missing records" bug report, check the sharing keyword on every class in the call stack before you touch the query itself. Explicitly declare with sharing / without sharing / inherited sharing on every class — don't rely on the default.

8. Async debug logs don't show up where you're looking

@future methods, Queueable jobs, and Batch Apex execute in a separate transaction, often minutes later, under a different debug log entry than the one you're staring at.

@future
public static void updateRelatedRecords(Set<Id> recordIds) {
    System.debug('This log will NOT appear in the log of the code that called this method');
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Fix: Set up a trace flag on the user, not just for a single anticipated transaction, and go to Setup → Debug Logs to find the log entry with a later timestamp and an "Application: Async" indicator. For batch jobs, check Setup → Apex Jobs for the Job ID, and consider writing key checkpoints to a custom object or Exception records so you have a permanent record instead of a log that expires.


Quick reference

Symptom Likely cause
Works with 1 record, fails on bulk import SOQL/DML inside a loop
Log is huge but missing the actual error Log level set to FINEST, middle got truncated
NullPointerException with no useful detail Unguarded relationship field traversal
User reports missing data, no error thrown Sharing rules / with sharing mismatch
System.debug never shows up Code ran in an async context — check Apex Jobs
Passes in test, fails in prod Test isolated by startTest/stopTest boundary

Debugging Apex is mostly about knowing where to look — the log level, the sharing context, and whether you're even looking at the right transaction. Get those three things right and most "mystery bugs" stop being mysterious.

What's the Apex bug that took you the longest to track down? Drop it in the comments.

Top comments (0)