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.
newcreates 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.
| Type | Value when unassigned | Typical initialisation |
|---|---|---|
| Integer, Long, Decimal, Double | null | = 0 |
| Boolean | null | = false |
| String | null | = '' |
| Date, Datetime, Id, Blob | null | Depends on use |
| List, Set, Map | null | = new List<Account>() |
sObject, such as Account a; | null | = new Account() |
| Custom class | null | = 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 list | SOQL for loop | |
|---|---|---|
| SOQL queries used | 1 | 1 |
| Rows count toward the 50,000 limit | Yes | Yes |
| Heap usage | The full result set sits in memory | About 200 records at a time |
| CPU cost | Lower | Higher |
| Results reusable after the loop | Yes | No |
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
queryandqueryMorecalls. - 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.Contactsinside 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.