System.ListException: List index out of bounds: <N>
You indexed a `List` past its size. Apex doesn't return null for out-of-range — it throws. The most common pattern is `[SELECT ... LIMIT 1][0]` against an empty list.
Also seen asList index out of bounds·ListException·System.ListException: List index·list out of bounds apex
A bug ticket lands in your inbox. The integration that imports CSVs from a vendor crashes once a week. The log reads System.ListException: List index out of bounds: 0. The CSV format hasn't changed in years. The integration code hasn't changed in months. You open the log and trace back to a single line: result = parsedRows[0];. The CSV was empty. The integration didn't check.
How indexing actually works in Apex
List<T> in Apex is a zero-indexed, bounded sequence. myList.get(n) and myList[n] are equivalent and both require 0 <= n < myList.size(). Outside that range, Apex throws System.ListException: List index out of bounds: <N>, where <N> is the index you asked for.
Apex does not return null for an out-of-range index. It does not silently grow the list. It does not return a default value. It throws a typed exception that the platform expects you to catch (or to prevent by checking the size first).
The bound check is strict: myList[myList.size()] throws, because valid indexes run from 0 to size() - 1 inclusive. Negative indexes throw too; Apex doesn't support Python-style wrap-around indexing.
The classic broken example
The most common appearance of ListException is the one-liner that takes the first row of a SOQL query without checking the result.
public static Account firstMatchingAccount(String name) {
return [SELECT Id, Name FROM Account WHERE Name = :name LIMIT 1][0];
}
This is a clever shortcut for "give me the one matching account." When the query matches, it works. When no account matches, the inline SOQL returns an empty list, and [0] throws ListException: List index out of bounds: 0.
The pattern is fragile in two specific ways:
- The failure mode depends on production data, not code paths.
- The error message ("List index out of bounds: 0") doesn't immediately point at the SOQL line; it points at the indexing operator. Junior developers looking at the stack trace go hunting for a list manipulation when the bug is the assumption that a query found a row.
The fix
Always check isEmpty() before indexing, or use size() > N if you need to access index N.
public static Account firstMatchingAccount(String name) {
List<Account> hits = [SELECT Id, Name FROM Account WHERE Name = :name LIMIT 1];
if (hits.isEmpty()) {
return null;
}
return hits[0];
}
The pattern is three lines instead of one, and it makes the empty-result case explicit. The caller knows the method can return null. The reader doesn't have to mentally simulate "what if the query matched nothing."
If a missing match should be a hard error (the business logic depends on the row existing), throw a typed exception instead of returning null:
public class AccountNotFoundException extends Exception {}
public static Account firstMatchingAccount(String name) {
List<Account> hits = [SELECT Id, Name FROM Account WHERE Name = :name LIMIT 1];
if (hits.isEmpty()) {
throw new AccountNotFoundException('No Account named "' + name + '" found.');
}
return hits[0];
}
A named exception is easier to handle upstream than ListException. Callers can catch the specific failure without catching every other list error in the system.
Off-by-one in for loops
The second common cause is an old-fashioned indexed loop that runs one iteration past the end:
for (Integer i = 0; i <= myList.size(); i++) { // Bug: note <=, not <
System.debug(myList[i]);
}
When i == myList.size(), the index is out of bounds. The loop throws on the last iteration.
Two fixes:
Use < not <=. The classic fix: for (Integer i = 0; i < myList.size(); i++). Valid indexes are 0 through size() - 1, so the loop bound is < size().
Use the foreach loop. Apex supports for (Type item : myList), which can't be off-by-one because there's no index variable to mismanage. Prefer this form whenever you don't need the index.
for (Case c : myList) {
process(c);
}
Parallel lists getting out of sync
A third common appearance is when code maintains two lists side by side and one grows differently from the other:
List<Id> recordIds = new List<Id>();
List<String> statuses = new List<String>();
for (Case c : cases) {
recordIds.add(c.Id);
if (c.Status != 'Closed') {
statuses.add(c.Status); // Conditionally added
}
}
// Later, assumed to be aligned:
for (Integer i = 0; i < recordIds.size(); i++) {
System.debug(recordIds[i] + ' -> ' + statuses[i]); // Fails if statuses is shorter
}
The fix is structural: don't maintain parallel lists. Use a Map<Id, String> (or a list of typed records). Maps are inherently consistent because each entry carries its own key and value together.
Map<Id, String> statusByCaseId = new Map<Id, String>();
for (Case c : cases) {
if (c.Status != 'Closed') {
statusByCaseId.put(c.Id, c.Status);
}
}
for (Id recordId : statusByCaseId.keySet()) {
System.debug(recordId + ' -> ' + statusByCaseId.get(recordId));
}
The map version is impossible to get out of sync. The fix doubles as a clarity improvement.
The fixed example, end to end
Take the CSV-import service that started this page and make it bulletproof:
public class VendorCsvImporter {
public class EmptyCsvException extends Exception {}
public static void importCsv(String csvText) {
if (String.isBlank(csvText)) {
throw new EmptyCsvException('CSV input is empty');
}
List<String> rows = csvText.split('\n');
if (rows.isEmpty()) {
throw new EmptyCsvException('CSV has no rows');
}
// Header is row 0; data starts at row 1.
if (rows.size() < 2) {
throw new EmptyCsvException('CSV has a header but no data rows');
}
List<Account> toInsert = new List<Account>();
for (Integer i = 1; i < rows.size(); i++) {
String line = rows[i].trim();
if (String.isBlank(line)) continue; // Skip blank lines
List<String> cells = line.split(',');
if (cells.size() < 3) {
System.debug(LoggingLevel.WARN, 'Skipping malformed row ' + i);
continue;
}
toInsert.add(new Account(
Name = cells[0].trim(),
Industry = cells[1].trim(),
BillingCountry = cells[2].trim()
));
}
if (!toInsert.isEmpty()) {
insert toInsert;
}
}
}
Five separate guards: blank input, no rows, no data rows, blank lines inside, malformed rows. Each guard is one to three lines. The function never indexes a list without first proving the index is valid.
Where it shows up in async and batch contexts
Batch Apex's execute() method receives a List<SObject> (the batch scope). If your code does scope[0] to look at the first record, it works as long as the platform passes you a non-empty scope. The platform's contract is that execute() is called only when the scope has rows, so the bug is unlikely to surface there. But chained logic that filters the scope down and then indexes the filtered list still needs the size check.
Queueable Apex receives whatever you constructed and handed to it. If you constructed an empty list and then your execute(QueueableContext) body assumes index 0 exists, you'll fault. Defensive: validate constructor arguments at job-creation time.
Negative indexes and arithmetic surprises
Apex doesn't support negative indexing. myList[-1] throws ListException: List index out of bounds: -1. If you're porting code from Python or JavaScript, the idiom for "last element" needs translation:
// Python: myList[-1]
// Apex equivalent:
T lastItem = myList[myList.size() - 1]; // Still throws if list is empty
Subtle bug: arithmetic that produces a negative index when a counter is misaligned. myList[someIndex - 1] is innocent-looking until someIndex == 0, at which point you're asking for myList[-1]. Guard the math, not just the index.
Testing
Write a test that explicitly exercises the empty-input path:
@isTest
static void importCsv_throwsOnEmptyInput() {
Test.startTest();
try {
VendorCsvImporter.importCsv('');
System.assert(false, 'Expected EmptyCsvException');
} catch (VendorCsvImporter.EmptyCsvException ex) {
System.assertNotEquals(null, ex.getMessage());
}
Test.stopTest();
}
And one that exercises the malformed-row path:
@isTest
static void importCsv_skipsMalformedRows() {
String csv = 'Name,Industry,Country\nAcme,Tech,US\nMalformed\nGlobex,Finance,UK';
Test.startTest();
VendorCsvImporter.importCsv(csv);
Test.stopTest();
Integer count = [SELECT COUNT() FROM Account WHERE Name IN ('Acme', 'Globex')];
System.assertEquals(2, count, 'Should import 2 well-formed rows, skip the malformed one');
}
Tests like these prevent regressions when someone "simplifies" the import code by removing the guards.
Family of related errors
| Exception | Cause |
|---|---|
ListException: List index out of bounds: <N> | Indexed past list size |
QueryException: List has no rows for assignment to SObject | SOQL into SObject variable, zero rows |
QueryException: assignment to SObject from non-singleton list | SOQL into SObject variable, two or more rows |
NullPointerException: Attempt to de-reference a null object | Dereferenced a null reference |
MathException: Divide by zero | Mathematical operation produced an undefined result |
All of these are "I assumed the data was there and it wasn't." The fix family is the same: prove the precondition before relying on it.
Why Apex chose to throw instead of return null
Languages that return null for out-of-range indexing (older PHP, ColdFusion in certain modes) often see a downstream NullPointerException two methods deeper, when someone tries to use the null value. The original site of the bug (the index call) and the symptom (the null dereference) are far apart, making diagnosis painful.
Apex's choice to throw immediately puts the diagnostic right at the failure site. The stack trace points at the indexing line. The fix is exactly there. This is the same design philosophy as throwing on [SELECT ... LIMIT 1] zero-row assignment, divide-by-zero, and many other "this should never happen at runtime" cases. The platform prefers loud, localized failures over silent, distant ones.
The trade-off is that you must remember to guard. The platform provides typed exceptions to make the bug class easy to catch, but it doesn't provide a way to opt into "return null instead of throwing." That's a deliberate design call.
A code review checklist for list indexing
Before approving Apex that indexes a list, scan for:
- Every
myList[N]ormyList.get(N)call where N is a literal: is the size guaranteed by an earlier check? - Every
myList[N]where N is a variable: where does N come from, and what's its range? - Every
myList[someExpr - 1]: cansomeExprbe 0? - Every for loop with
<=: should it be<? - Every pair of lists referenced by the same index variable: are they guaranteed to stay the same size?
The checklist catches most bugs in two minutes. Build it into your team's PR template.
Performance: indexing vs iterating vs converting
A subtle question: if you need to scan a list to find a record, is it faster to use a for-each loop or to index? In Apex, both have the same time complexity (O(1) for index access, O(n) for the full scan). The difference is readability: the for-each form is harder to mis-write and reads more naturally. Use it unless you specifically need the index value.
For large lists where you need fast lookup by some key, converting to a Map<KeyType, T> once and then doing O(1) get calls beats repeated linear scans. A common pattern:
Map<Id, Account> accountById = new Map<Id, Account>(accounts);
Account a = accountById.get(someId); // Null if missing; no exception
The map version replaces ListException risk with NullPointerException risk on the dereference, which is at least a different exception class and arguably a clearer signal of intent ("I'm looking up a record by Id; it might not exist").
How this looks in LWC and Aura calls
If a @AuraEnabled method throws ListException, the framework converts it to an AuraHandledException with a generic message before sending it to the browser. The user sees something like "An error occurred" with no details. The actual ListException stack trace is only in the server-side Apex log.
For better user experience, catch the exception in the Apex method and translate it into a meaningful AuraHandledException:
@AuraEnabled
public static Account getAccountByName(String name) {
try {
List<Account> hits = [SELECT Id, Name FROM Account WHERE Name = :name LIMIT 1];
if (hits.isEmpty()) {
throw new AuraHandledException('No account found with name "' + name + '".');
}
return hits[0];
} catch (Exception ex) {
throw new AuraHandledException(ex.getMessage());
}
}
The browser-side LWC code now gets a clear error message it can display, instead of a generic platform error.
Putting it together
ListException: List index out of bounds is one of the easiest Apex errors to diagnose once you know the rule, and one of the easiest to prevent once you build the habit of checking isEmpty() before indexing. The fix is mechanical. The discipline is what takes practice.
Code that handles list emptiness correctly is more verbose than code that doesn't, but the verbosity buys you immunity to a whole class of production failures. The four extra lines per query are the cheapest insurance in your Apex toolkit.
Further reading from Salesforce
- Apex Developer Guide: Lists
- Apex Developer Guide: Exception Class and Built-In Exceptions
- Apex Developer Guide: SOQL and SOSL Queries
- Apex Developer Guide: Maps
- Trailhead: Apex Specialist Superbadge
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
ApexSalesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A …
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…