DEV Community

Samcorp
Samcorp

Posted on

Debugging Governor Limits in a 4M-Record Org

Debugging Governor Limits in a 4M-Record Org
Apex governor limits are easy to understand when your Salesforce org has 20,000 records.

They become much more interesting when one object has more than 4 million.

At that scale, code that looked perfectly reasonable for years can suddenly start failing with errors such as:

System.LimitException: Too many SOQL queries: 101
Enter fullscreen mode Exit fullscreen mode

or:

System.LimitException: Too many query rows: 50001
Enter fullscreen mode Exit fullscreen mode

or the less obvious:

System.LimitException: Apex CPU time limit exceeded
Enter fullscreen mode Exit fullscreen mode

The instinctive response is usually:

“We need to bulkify the trigger.”

Sometimes that is correct.

But in a large-data-volume org, bulkification is often only the beginning.

This write-up walks through a representative debugging scenario involving Apex governor limits, roughly four million records, a trigger that had worked for years, and a fix that required changing more than a for loop.

Note: The scenario and object names below are representative examples designed to demonstrate the debugging process. They are not a customer post-mortem.


The Problem

Imagine an organization with a custom object called:

Usage_Event__c
Enter fullscreen mode Exit fullscreen mode

It stores usage events associated with customer accounts.

Over several years, the table has grown to approximately:

4,120,000 records
Enter fullscreen mode Exit fullscreen mode

Whenever an Account changes, an Apex trigger recalculates some usage information.

The original implementation looked roughly like this:

for (Account account : Trigger.new) {

    List<Usage_Event__c> events = [
        SELECT Id, Duration__c, Processed__c
        FROM Usage_Event__c
        WHERE Account__c = :account.Id
        AND Processed__c = false
    ];

    Decimal totalDuration = 0;

    for (Usage_Event__c eventRecord : events) {
        totalDuration += eventRecord.Duration__c;
    }

    account.Current_Usage__c = totalDuration;
}
Enter fullscreen mode Exit fullscreen mode

If one Account was updated manually, nothing looked wrong.

One query.

A few records.

Fast execution.

Then a bulk integration started updating Accounts in groups of 200.

This is especially important in Salesforce integration architecture, where external systems can turn what looks like a single-record workflow into sustained bulk transactions.

That changed everything.


Failure #1: Too Many SOQL Queries

Salesforce can execute triggers for collections of records.

So if 200 Accounts enter this trigger, the query inside the loop may execute 200 times.

That quickly reaches the synchronous Apex SOQL limit.

The first failure was predictable:

System.LimitException: Too many SOQL queries: 101
Enter fullscreen mode Exit fullscreen mode

The obvious fix was to bulkify the query.


Fix Attempt #1: Bulkify Everything

We collected the Account IDs first:

Set<Id> accountIds = new Set<Id>();

for (Account account : Trigger.new) {
    accountIds.add(account.Id);
}
Enter fullscreen mode Exit fullscreen mode

Then queried everything in one operation:

List<Usage_Event__c> events = [
    SELECT Id, Account__c, Duration__c, Processed__c
    FROM Usage_Event__c
    WHERE Account__c IN :accountIds
    AND Processed__c = false
];
Enter fullscreen mode Exit fullscreen mode

Much better.

We had gone from potentially 200 queries to one.

Problem solved?

Not quite.

The next test failed differently.

System.LimitException: Too many query rows: 50001
Enter fullscreen mode Exit fullscreen mode

That was the moment the problem changed.

We were no longer debugging a simple trigger anti-pattern.

We were debugging large data volume.


Bulkified Does Not Mean Scalable

This is an important distinction.

This:

for (Account account : Trigger.new) {
    // SOQL
}
Enter fullscreen mode Exit fullscreen mode

is bad because the number of queries grows with the number of trigger records.

But replacing it with one giant query does not guarantee scalability.

We had removed one problem:

Too many SOQL queries
Enter fullscreen mode Exit fullscreen mode

and exposed another:

Too many queried rows
Enter fullscreen mode Exit fullscreen mode

Our transaction was now logically bulkified but still trying to bring too much data into one Apex execution context.

That is where debugging Apex governor limits becomes more architectural.


Start With the Transaction, Not the Exception

When a governor limit fails, the last line in the stack trace is not always the real problem.

Instead, I want to know:

What entered the transaction?
        ↓
What automation executed?
        ↓
What queried data?
        ↓
How much data came back?
        ↓
What happened to that data?
        ↓
Which limit grew fastest?
Enter fullscreen mode Exit fullscreen mode

That produces a much more useful investigation.

Salesforce transactions may include more than the Apex class you are currently reading.

They can involve:

  • triggers,
  • record-triggered flows,
  • validation logic,
  • managed-package automation,
  • workflow-related actions,
  • additional DML,
  • additional trigger executions.

So “my handler only runs three queries” does not necessarily mean the transaction runs only three queries.

The governor belongs to the transaction.

Not your class.


Add Limit Instrumentation

Debug logs are useful, but I also like adding temporary instrumentation around suspicious sections.

For example:

System.debug(
    'SOQL: ' +
    Limits.getQueries() +
    '/' +
    Limits.getLimitQueries()
);

System.debug(
    'Rows: ' +
    Limits.getQueryRows() +
    '/' +
    Limits.getLimitQueryRows()
);

System.debug(
    'CPU: ' +
    Limits.getCpuTime() +
    '/' +
    Limits.getLimitCpuTime()
);

System.debug(
    'DML: ' +
    Limits.getDmlStatements() +
    '/' +
    Limits.getLimitDmlStatements()
);
Enter fullscreen mode Exit fullscreen mode

Then repeat the measurement around expensive operations.

For example:

System.debug('Before usage query CPU: ' + Limits.getCpuTime());

List<Usage_Event__c> events = [
    SELECT Id, Account__c, Duration__c
    FROM Usage_Event__c
    WHERE Account__c IN :accountIds
    AND Processed__c = false
];

System.debug('After usage query CPU: ' + Limits.getCpuTime());
System.debug('Query rows: ' + Limits.getQueryRows());
Enter fullscreen mode Exit fullscreen mode

Do not leave noisy debugging permanently in production code.

But during diagnosis, these checkpoints make it much easier to see where the transaction begins consuming its budget.


The Limits That Mattered

For synchronous Apex, several limits deserve immediate attention during this type of investigation.

Resource Synchronous Limit
SOQL queries 100
SOQL rows retrieved 50,000
DML statements 150
DML rows 10,000
Apex CPU time 10,000 ms

Asynchronous Apex provides more room for certain limits, including a higher SOQL-query allowance and significantly more CPU time.

But moving bad code asynchronous is not automatically a fix.

You can still write an inefficient asynchronous transaction.


Failure #2: The Query Was Too Broad

The next question was simple:

Why were we retrieving so many Usage_Event__c records?

The query looked innocent:

SELECT Id, Account__c, Duration__c
FROM Usage_Event__c
WHERE Account__c IN :accountIds
AND Processed__c = false
Enter fullscreen mode Exit fullscreen mode

But the data distribution mattered.

Suppose:

Total Usage_Event__c records:     4,120,000
Processed__c = false:             1,480,000
Enter fullscreen mode Exit fullscreen mode

Processed__c = false is not narrowing the table very much.

And several large customers might each own tens of thousands of usage events.

A filter can be logically correct while still being operationally expensive.

Check the Query Plan

This is where I would stop guessing.

Use Salesforce's Query Plan tooling and inspect how the optimizer expects to execute the SOQL.

Things I want to know include:

  • Is the query using an index?
  • Is Salesforce considering a table scan?
  • How many rows does the optimizer expect?
  • What is the relative cost?
  • Is one filter far less selective than expected?

This matters because:

Indexed does not automatically mean selective.

A field may have an index and still match so much of the table that using the index provides little benefit.

With four million records, data distribution becomes part of application design.


The Real Question: Why Are We Reading History?

Then we found the more important design problem.

Every Account update caused the application to ask:

Give me every unprocessed usage event belonging to this Account.

But most Account changes had nothing to do with usage.

Changing:

BillingCity
Enter fullscreen mode Exit fullscreen mode

could trigger the same expensive calculation as changing a field that actually affected usage.

That is wasted work.

The first architectural improvement was therefore not another SOQL optimization.

It was reducing when the calculation ran.

Only Execute When Relevant Fields Change

Instead of recalculating on every Account update:

for (Account account : Trigger.new) {
    calculateUsage(account);
}
Enter fullscreen mode Exit fullscreen mode

we identified whether relevant fields actually changed.

Conceptually:

Set<Id> accountsNeedingRecalculation = new Set<Id>();

for (Account account : Trigger.new) {

    Account oldAccount = Trigger.oldMap.get(account.Id);

    if (
        account.Usage_Mode__c != oldAccount.Usage_Mode__c ||
        account.Subscription__c != oldAccount.Subscription__c
    ) {
        accountsNeedingRecalculation.add(account.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now an update to an unrelated field does not start an expensive calculation.

Sometimes the cheapest query is the query you never run.


Failure #3: CPU Became the Next Bottleneck

After reducing the query volume, another test exposed a different failure:

System.LimitException: Apex CPU time limit exceeded
Enter fullscreen mode Exit fullscreen mode

Why?

Because retrieving data was only half of the problem.

The code was also doing work like:

for (Usage_Event__c eventRecord : events) {

    // calculations
    // string transformations
    // map lookups
    // nested conditions
    // repeated aggregation
}
Enter fullscreen mode Exit fullscreen mode

At thousands of iterations per Account, CPU consumption started climbing quickly.

This is a common pattern when debugging governor limits.

You fix:

SOQL queries
Enter fullscreen mode Exit fullscreen mode

then discover:

query rows
Enter fullscreen mode Exit fullscreen mode

then fix that and discover:

CPU
Enter fullscreen mode Exit fullscreen mode

Governor limits often expose inefficiencies layer by layer.


Stop Bringing Back Records You Only Want to Count

This was another useful question:

Do we actually need the individual Usage_Event__c records?

In our example, we mainly needed totals.

The original pattern effectively did this:

List<Usage_Event__c> events = [
    SELECT Account__c, Duration__c
    FROM Usage_Event__c
    WHERE ...
];

Map<Id, Decimal> totals = new Map<Id, Decimal>();

for (Usage_Event__c eventRecord : events) {

    Decimal current =
        totals.containsKey(eventRecord.Account__c)
        ? totals.get(eventRecord.Account__c)
        : 0;

    totals.put(
        eventRecord.Account__c,
        current + eventRecord.Duration__c
    );
}
Enter fullscreen mode Exit fullscreen mode

But the database is much better positioned to perform aggregation.

Depending on the use case, an aggregate query may look more like:

List<AggregateResult> results = [
    SELECT
        Account__c accountId,
        SUM(Duration__c) totalDuration
    FROM Usage_Event__c
    WHERE Account__c IN :accountIds
    AND Processed__c = false
    GROUP BY Account__c
];
Enter fullscreen mode Exit fullscreen mode

Now Apex receives summary results rather than every matching row.

That means:

  • fewer objects in memory,
  • less Apex iteration,
  • simpler business logic,
  • lower heap pressure,
  • potentially lower CPU consumption.

An aggregate query is not magic.

The underlying filters still need to scale.

But it is wasteful to retrieve 30,000 records when the application only needs one number.


Add a Real Data Boundary

The original query also had no meaningful time boundary.

It asked for every matching record from years of history.

But the business requirement only needed recent usage.

So instead of:

WHERE Account__c IN :accountIds
AND Processed__c = false
Enter fullscreen mode Exit fullscreen mode

the query could potentially become something closer to:

WHERE Account__c IN :accountIds
AND Processed__c = false
AND CreatedDate >= :cutoffDate
Enter fullscreen mode Exit fullscreen mode

CreatedDate is one of the fields Salesforce can index.

More importantly, the new predicate represents an actual business boundary.

Good query optimization is not:

“How can I trick the optimizer?”

It is:

“What data does the transaction genuinely need?”


Backfills Do Not Belong in a User Transaction

We still had another requirement.

Historical usage needed recalculation across millions of records.

Trying to do that during an ordinary record update would never be a healthy design.

This was a different workload.

So it deserved a different execution model.

Batch Apex for the Historical Work

Batch Apex is designed for processing large datasets in independent chunks.

A simplified example:

public class UsageBackfillBatch
    implements Database.Batchable<SObject> {

    public Database.QueryLocator start(
        Database.BatchableContext context
    ) {

        return Database.getQueryLocator([
            SELECT Id, Account__c, Duration__c
            FROM Usage_Event__c
            WHERE CreatedDate >= :startDate
            AND CreatedDate < :endDate
        ]);
    }

    public void execute(
        Database.BatchableContext context,
        List<Usage_Event__c> scope
    ) {

        // Process the current chunk.
    }

    public void finish(
        Database.BatchableContext context
    ) {

        // Logging, reconciliation, or next step.
    }
}
Enter fullscreen mode Exit fullscreen mode

The important part is not simply that Batch Apex is asynchronous.

The important part is the transaction boundary.

Each batch execution gets its own governor-limit context.

Salesforce also allows a Batch Apex Database.QueryLocator to work with very large result sets—up to tens of millions of records—making it much more appropriate for a four-million-record backfill than a normal synchronous transaction.


Queueable vs Batch

A useful rule of thumb is:

Use Queueable Apex When

  • work is reasonably bounded,
  • you need asynchronous execution,
  • jobs need chaining,
  • you are processing a specific known workload.

Use Batch Apex When

  • the dataset itself is large,
  • millions of records may need processing,
  • work needs to be split into independent chunks,
  • limits need to reset between chunks.

Do not move everything asynchronous simply because synchronous code reaches a limit.

Choose the execution model based on the workload.


The Better Architecture

After working through the failures, the architecture looked very different.

Before

Account Update
      ↓
Trigger
      ↓
Query historical usage
      ↓
Load thousands of records
      ↓
Calculate totals in Apex
      ↓
Update account
Enter fullscreen mode Exit fullscreen mode

Every transaction did far too much work.

After

Account Update
      ↓
Did a relevant field change?
      ↓
     Yes
      ↓
Query only required data
      ↓
Database-side aggregation
      ↓
Update required values
Enter fullscreen mode Exit fullscreen mode

Historical work followed a separate path:

Historical Recalculation
      ↓
Batch Apex
      ↓
Selective QueryLocator
      ↓
Small execution scopes
      ↓
Process
      ↓
Reconcile results
Enter fullscreen mode Exit fullscreen mode

This separation was much more important than any single code optimization.


What Actually Fixed the Governor Limits?

There was no magic line of Apex.

The fix was a combination of decisions.

1. Removed SOQL From Loops

This eliminated the immediate 101 queries failure.

2. Reduced Unnecessary Execution

The expensive logic stopped running for unrelated Account changes.

3. Made Queries More Selective

We stopped treating the four-million-row object like a small lookup table.

4. Used Query Plan Instead of Guessing

We verified how Salesforce expected to execute important SOQL.

5. Retrieved Less Data

Transactions asked only for data required by the business operation.

6. Moved Aggregation Toward the Database

Apex stopped iterating through large collections merely to calculate totals.

7. Separated Transactional Work From Backfills

User-facing transactions remained small.

Historical processing moved to Batch Apex.

8. Tested With Production-Like Data Volume

The original implementation had looked fine because the development environment did not reproduce production cardinality.

That last point deserves more attention.


Your Sandbox Can Lie to You

Suppose a development sandbox contains:

12,000 Usage_Event__c records
Enter fullscreen mode Exit fullscreen mode

and production contains:

4,120,000
Enter fullscreen mode Exit fullscreen mode

The same SOQL statement may behave very differently.

A test can prove functional correctness while telling you almost nothing about large-volume performance.

For important LDV paths, ask:

  • What is production row count?
  • How are records distributed?
  • Are a few parent records heavily skewed?
  • How many records match common filters?
  • Does the Query Plan change at production volume?
  • What happens when 200 records enter the trigger?
  • Which other automation shares the transaction?

Performance testing requires realistic cardinality, not merely realistic field values.


A Governor-Limit Debugging Checklist

When I see an Apex limit exception, this is the order I would investigate it.

SOQL

  • Is any query inside a loop?
  • How many queries does the entire transaction execute?
  • How many rows does each query return?
  • Are the WHERE filters selective?
  • What does Query Plan report?
  • Are we retrieving fields or records we never use?

DML

  • Is DML happening inside loops?
  • Can updates be collected and performed together?
  • Are we updating records whose values did not actually change?
  • Is DML recursively triggering additional automation?

CPU

  • Are there nested loops?
  • Are large collections repeatedly scanned?
  • Can Sets or Maps replace repeated searches?
  • Is Flow or other automation consuming the same transaction?
  • Are calculations being repeated unnecessarily?

Heap

  • Are we loading large record collections?
  • Are we selecting large text fields we do not need?
  • Can data be processed incrementally?
  • Can aggregation happen in SOQL?

Architecture

  • Does this work need to happen synchronously?
  • Is this a transactional operation or a backfill?
  • Would Queueable or Batch Apex fit better?
  • Can the system process only changes instead of recalculating history?

The Biggest Lesson

The hardest part of debugging Apex governor limits in a large Salesforce org is realizing that the limit exception is often not the real bug.

The real bug may be an incorrect assumption.

An assumption such as:

“This table will stay small.”

or:

“An Account only has a few child records.”

or:

“This trigger normally receives one record.”

or:

“Bulkifying one query makes the solution scalable.”

Those assumptions can remain invisible for years.

Then the organization reaches four million records and the architecture finally has enough data to prove them wrong.

Governor limits are not just restrictions developers need to work around.

They force us to design transactions with explicit resource boundaries.

And at scale, that usually leads to better systems.

These problems are why scalable Salesforce development requires more than working Apex code; data volume, transaction boundaries, automation, and long-term platform behavior all need to be considered together.


Final Takeaway

If I had to reduce this debugging exercise to five rules, they would be:

  1. Measure the whole transaction, not just your class.
  2. Bulkification is mandatory, but it is not the same as scalability.
  3. Query selectivity matters more as data volume grows.
  4. Do not retrieve data that the transaction does not need.
  5. Move large historical workloads into execution models designed for them.

Four million records should not automatically break Apex.

But four million records will expose code that was designed as if the database would always remain small.

And that is exactly why large-data-volume debugging is such a useful test of Salesforce architecture.

Top comments (0)