Apex

Apex NullPointerException Explained — Null vs Empty, Collections and SOQL For Loops

By Rishabh Panwar · 5 min read · Intermediate

A trigger passes every test, goes to production, and on day two throws System.NullPointerException: Attempt to de-reference a null object because one record arrived without an Industry value. Almost every Apex NullPointerException comes from a handful of rules about how Apex treats variables, collections and query results. Once you know them, most of these bugs become easy to spot in code review. SOQL for loops come up in the same conversations, so they’re covered at the end.

Several limits come up along the way, and the Apex governor limits guide has the full list. Trigger handlers are where null-safe collection code pays off most, which the trigger frameworks guide shows in context.

A variable and an object are different things

List<Integer> a;                         // the variable exists and holds null
List<Integer> b = new List<Integer>();   // the variable points at a real, empty list
  • Declaring a variable creates the variable. new creates the object.
  • Null means the variable points at nothing. Empty means an object exists and has no contents.
  • Method calls run on the object. With no object, you get the null pointer exception.

Assignment copies the reference, so two variables can point at the same object:

List<Integer> a = new List<Integer>{ 1, 2 };
List<Integer> b = a;   // copies the reference
b.add(3);
System.debug(a.size()); // 3, because a and b are the same list

Apex has no default values

Unlike Java primitives, every unassigned Apex variable is null. Integer i; holds null. It doesn’t start at 0. Apex Integer behaves like Java’s Integer wrapper class.

TypeValue when unassignedTypical initialisation
Integer, Long, Decimal, Doublenull= 0
Booleannull= false
Stringnull= ''
Date, Datetime, Id, BlobnullDepends on use
List, Set, Mapnull= new List<Account>()
sObject, such as Account a;null= new Account()
Custom classnull= new MyClass()

The three that cause most bugs

Booleans have three states

Boolean flag;
if (flag) { }          // throws: flag is null
if (flag == true) { }  // safe: evaluates to false

Checkbox fields on sObjects are never null. Boolean variables and methods that return Boolean can be.

Arithmetic on null throws

Integer count;
count++;                              // throws

Map<Id, Integer> totals = new Map<Id, Integer>();
Integer x = totals.get(someId);       // null if the key is missing
Integer y = x + 1;                    // throws

A null String and an empty String behave differently

String s;
s.length();          // throws: instance method on null
String.isBlank(s);   // true: static method, safe on null
s == null;           // true: == is null-safe for Strings
'abc' + s;           // 'abcnull': concatenation tolerates null

The rule of thumb: instance methods throw on null, static methods don’t. Reach for String.isBlank() by default, since it covers null, empty and whitespace-only values in one call.

Collections

Map<Id, Account> m;                             // null: m.put() throws
Map<Id, Account> m2 = new Map<Id, Account>();   // empty: m2.put() works

Calling m2.get(missingKey) doesn’t throw. It quietly returns null, and the exception arrives one step later, when you write something like m2.get(id).Name. Guard the lookup with containsKey() or check the value you got back. Loops follow the same logic: looping over a null collection throws, while looping over an empty one simply runs zero times.

SOQL never returns null into a list

List<Order> orders = [SELECT Id FROM Order WHERE AccountId IN :accountIds];

if (orders != null) { }     // always true, so this check does nothing
if (!orders.isEmpty()) { }  // the check you actually want

Assigning a query to a single record is different: Account a = [SELECT Id FROM Account WHERE Id = :someId]; throws QueryException when no row matches.

When a value genuinely might be null, for example a method parameter, use a short-circuit check:

if (records != null && !records.isEmpty()) { }

The && stops at the first false, so isEmpty() never runs on null.

Initialise class members where you declare them

private List<Order> orders = new List<Order>();

Leave a member null only when “not set yet” means something different from “zero” or “empty”, such as Date lastRunDate.

SOQL for loop vs querying into a list

// A: query into a list
List<Order> orders = [SELECT Id FROM Order WHERE AccountId IN :accountIds];
for (Order o : orders) { }

// B: SOQL for loop
for (Order o : [SELECT Id FROM Order WHERE AccountId IN :accountIds]) { }

Neither version is the anti-pattern. Both run one query. The anti-pattern is a query inside a loop body.

Query into a listSOQL for loop
SOQL queries used11
Rows count toward the 50,000 limitYesYes
Heap usageThe full result set sits in memoryAbout 200 records at a time
CPU costLowerHigher
Results reusable after the loopYesNo

Default to querying into a list. It is cheaper on CPU, easier to read, and you usually need the results again. In a trigger you are handling at most 200 records per chunk anyway, so heap is rarely the constraint.

Use a SOQL for loop when heap is the real problem: a large result set that you process once and throw away.

SOQL for loop details worth knowing

  • There are two forms. The single-record form runs the loop body once per record. The list form, for (List<Order> chunk : [SELECT ...]), runs once per 200 records. If you do DML inside the loop, use the list form so each DML statement handles a whole chunk. The single-record form with DML inside hits the 150-statement limit on record 151.
  • Chunking has a CPU cost. The platform fetches records through internal query and queryMore calls.
  • Aggregate queries can’t use queryMore. An aggregate query in a SOQL for loop throws a runtime exception if it returns more than 2,000 rows.
  • Watch parent-child subqueries. Reading acct.Contacts inside a single-record SOQL for loop fails once an account has more than 200 contacts. Iterate the child list directly instead of assigning it to a variable.
  • The results aren’t available afterwards. There is no .size(), no second pass and nothing to pass to another method.
  • The 50,000-row limit still applies. Above that, use Batch Apex.

Quick recall

  • Apex has no default values; anything unassigned is null.
  • Empty is a valid state. Null means no object was ever created.
  • Instance methods throw on null; static helpers like String.isBlank() don’t.
  • SOQL into a list returns empty, never null, so check isEmpty().
  • Map.get() on a missing key returns null silently.
  • A SOQL for loop saves heap, costs CPU, and still counts rows toward 50,000.
  • Query into a list unless you can name the heap problem you are avoiding.

Explaining it when asked

On null vs empty: explain that they are different failure modes. Empty is a normal state you handle with isEmpty(). Null means the object was never created, and touching it throws. SOQL list results are never null, so you only need null checks where values come from somewhere you don’t control: map lookups, method parameters and uninitialised members.

On SOQL for loops: say you query into a list by default because it is cheaper on CPU and reusable. You switch to a SOQL for loop when the result set threatens the 6 MB synchronous heap limit and you need a single pass. Beyond 50,000 rows, you move to Batch Apex because the for loop doesn’t lift the row limit.

Frequently asked questions

Why does Apex throw 'Attempt to de-reference a null object'?

You called a method or accessed a property on a variable that holds null. Declaring a variable doesn't create an object; only new (or an assignment) does. Every unassigned variable in Apex is null, including Integer and Boolean.

Can a SOQL query return null in Apex?

No. A query assigned to a list returns an empty list when nothing matches, so check isEmpty() and skip the null check. A query assigned to a single sObject variable throws a QueryException when no row matches.

What does Map.get() return for a missing key?

It returns null without throwing. The exception comes later, when you use the returned value, for example map.get(id).Name. Guard with containsKey() or check the returned value.

Is a SOQL for loop better than querying into a list?

Both use one SOQL query. Querying into a list uses less CPU and lets you reuse the results, so it is the better default. A SOQL for loop processes records in chunks of 200 to save heap, which helps only when the result set is large enough to threaten the heap limit.

Does a SOQL for loop get around the 50,000-row limit?

No. Rows retrieved by a SOQL for loop still count toward the 50,000-row query limit. For larger volumes, use Batch Apex.

How do I check if a String is null or empty in Apex?

Use String.isBlank(value). It is a static method, so it doesn't throw on null, and it returns true for null, empty and whitespace-only strings.