Fix Common Errors

NSRangeException: Index Beyond Bounds — Fix It

NSRangeException crashes when you index past the end of an NSArray or NSString. Read the crash log, find the offending frame, and prevent it in Swift.

If your iOS app has ever crashed with a message like *** -[__NSArrayI objectAtIndexedSubscript:]: index 5 beyond bounds [0 .. 3], you've hit an NSRangeException — one of the most frustrating Foundation collection crashes. The message is precise and terrible: you asked for element 5 in an array that only has 4 elements (indices 0–3). But the question remains: why did your code ask for something that doesn't exist?

NSRangeException is an Objective-C runtime exception raised when you access a collection by index or range outside its valid bounds. It happens with NSArray, NSMutableArray, NSString, and NSMutableString when you use methods like objectAtIndex:, subscript, substringWithRange:, or range-based mutations. The bounds notation [0 .. 3] means indices 0 through 3 inclusive — a 4-element collection. When a collection is empty, the message says [0 .. -1], a hint that even index 0 is beyond bounds.

What the error means

The full crash message contains three critical pieces:

*** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSArrayI objectAtIndexedSubscript:]: index 5 beyond bounds [0 .. 3]'
  • -[__NSArrayI objectAtIndexedSubscript:] — the Objective-C selector (method) that failed. __NSArrayI is the internal immutable array class; similar classes like __NSArrayM (mutable) exist.
  • index 5 — the index you tried to access.
  • [0 .. 3] — the valid range; element 3 is the last valid index, so there are 4 elements total.

If you see an empty-collection crash, it reads:

*** -[__NSArrayI objectAtIndexedSubscript:]: index 0 beyond bounds [0 .. -1]

The [0 .. -1] range is the collection's way of saying "you have zero elements."

Why you cannot catch this from Swift

Here is the critical pitfall: an NSRangeException is an Objective-C NSException, not a Swift Error. Your do/try/catch blocks do nothing.

do {
    let item = array[5]  // If array has 4 elements, this throws NSRangeException
} catch {
    // This will NEVER execute
    print("Caught:", error)
}
// The app crashes here anyway.

NSRangeException bypasses Swift error handling entirely. It calls objc_exception_throw, which raises a signal that calls abort(), triggering SIGABRT and terminating the app. The only defense is to not do it in the first place.

NSRangeException from Foundation collections cannot be caught by Swift's do/try/catch. Unlike Cocoa NSError, Objective-C exceptions are fatal. The only fix is to guard the access before attempting it. For a deeper explanation of why Swift and Objective-C error handling diverge, see Swift do/try/catch vs NSException.

Why it happens: causes and fixes

Direct out-of-range subscript

The simplest cause: you ask for an index that does not exist.

let array = ["a", "b", "c"]
let item = array[5]  // Crash: 5 is beyond [0 .. 2]

Fix: Guard with indices.contains(_:) before accessing, or use a safe-subscript extension:

// Option 1: Guard with indices
if array.indices.contains(5) {
    let item = array[5]
}

// Option 2: Safe subscript extension (classic pattern)
extension Array {
    subscript(safe index: Int) -> Element? {
        return indices.contains(index) ? self[index] : nil
    }
}

// Now usage is safe:
if let item = array[safe: 5] {
    print(item)
}

Off-by-one error with count

A common mistake: using count as an index instead of recognizing it is one past the last valid index.

let array = ["a", "b", "c"]  // count is 3
let item = array[array.count]  // Crash: index 3 is beyond [0 .. 2]

The last valid index is always count - 1. Replace any array[array.count] with array[array.count - 1] if you truly want the last element, or better yet, use array.last.

Stale data source vs UITableView or UICollectionView

This is the most common real-world cause in iOS apps. Your model updates asynchronously, but the table/collection view datasource methods return a stale count.

// In ViewController
var items: [String] = []

func updateItems() {
    Task {
        let newItems = await fetchItems()  // Network call
        self.items = newItems  // Items count changed
        DispatchQueue.main.async {
            self.tableView.reloadData()  // Table reloads on main thread
        }
    }
}

// In UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return items.count  // DANGER: If fetchItems completes while reloadData is mid-flight,
                        // this returns a new count but cellForRowAt still expects old indices
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    cell.textLabel?.text = items[indexPath.row]  // Crash if items was cleared
    return cell
}

Fix: Reload the table on the main thread before updating the model, or guard the access:

// Option 1: Reload first, update model after
func updateItems() {
    Task {
        let newItems = await fetchItems()
        DispatchQueue.main.async {
            self.items = newItems
            self.tableView.reloadData()  // Reload *after* updating items
        }
    }
}

// Option 2: Guard in cellForRowAt
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
    if indexPath.row < items.count {
        cell.textLabel?.text = items[indexPath.row]
    }
    return cell
}

NSString range operations and UTF-16 encoding

NSString is UTF-16 encoded under the hood, but Swift String counts characters. When you construct an NSRange from a Swift range, the indices do not always align.

let str = "Hello 👋"  // The wave emoji is 1 character but 2 UTF-16 code units
let nsstr = str as NSString
let range = NSRange(location: 6, length: 2)  // Trying to grab the emoji
let result = nsstr.substring(with: range)  // May crash or return garbage

Fix: Use String's native range methods instead:

let str = "Hello 👋"
if let startIdx = str.index(str.startIndex, offsetBy: 6, limitedBy: str.endIndex) {
    let substring = String(str[startIdx...])  // Safe, handles UTF-16 correctly
}

Mutating a collection while iterating

Removing an object at an index during iteration shifts all subsequent indices.

var array = ["a", "b", "c", "b", "d"]
for i in 0..<array.count {
    if array[i] == "b" {
        array.remove(at: i)  // Indices shift; next iteration may crash
    }
}

Fix: Iterate in reverse, or collect indices to remove and remove them after:

// Option 1: Iterate in reverse
for i in (0..<array.count).reversed() {
    if array[i] == "b" {
        array.remove(at: i)  // Safe, removes from end backward
    }
}

// Option 2: Collect and remove after
var indicesToRemove: [Int] = []
for i in 0..<array.count {
    if array[i] == "b" {
        indicesToRemove.append(i)
    }
}
for i in indicesToRemove.reversed() {
    array.remove(at: i)
}

// Option 3: Use filter
array = array.filter { $0 != "b" }

Batch updates inconsistent with model

When using insertRows(at:with:) or deleteRows(at:with:), the number of rows in your datasource must match the table's state before and after the batch update.

func deleteItem(at index: Int) {
    items.remove(at: index)  // Remove from model first
    
    DispatchQueue.main.async {
        self.tableView.beginUpdates()
        self.tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .fade)
        self.tableView.endUpdates()  // Table now asks for updated numberOfRowsInSection
    }
}

If numberOfRowsInSection returns a count that does not match the model after the delete, the table will try to access an out-of-bounds index when it asks for the next cell to render. Always ensure your model is consistent before signaling the batch update.

Debugging in Xcode

When an NSRangeException crashes, the stack trace often points to main.swift or UIApplicationMain, which is not useful. Set an Exception Breakpoint to stop at the moment the exception is thrown:

  1. In Xcode, open the Breakpoints Navigator (Cmd+8).
  2. Click the + button, select Exception Breakpoint.
  3. Set the exception type to Objective-C.
  4. Run your app; it will pause at the exact line that triggered the crash.

From the debugger, you can inspect the array, check its count, and verify the index. You also read the crash log's Last Exception Backtrace to see the call stack at the moment of the throw — this often pinpoints the offending table reload or fetch callback.

Swift-native arrays: a different crash

If you are using a Swift-native Array and go out of bounds, you get a different crash altogether:

Fatal error: Index out of range

This is EXC_BREAKPOINT (a trap instruction), not NSRangeException (SIGABRT). It means you called a method on a Swift Array that validated its bounds and failed — not a Foundation collection. Same fix (guard before accessing), but the crash signature is different. For the full breakdown, see Swift fatal error vs exceptions.

Preventing it in production

In production, crashes like this are silent until users report them — or they are buried in logs. Error tracking for iOS captures the exact stack frame, the affected user, and the preceding breadcrumbs, so you see not just that an NSRangeException happened, but where your datasource count disagreed with your model, or which async callback left your array empty. Reading iOS crash logs teaches you to extract the Thread Exception Backtrace and cross-reference it with your source.

Start tracking errors in minutes

Catch NSRangeException and all Foundation crashes before users do — LightTrace's iOS SDK captures crash reports, symbolication, and user session context.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.