Swift Index Out of Range: Bounds Checks That Work
Check count, guard empty lists, and add a safe subscript.
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓Basic Swift arrays and loops
- ✓Table views or simple lists
- ✓Optionals and guard let basics
- Swift arrays accept indices 0 through count minus 1, so reading index count or below zero crashes instantly
- Loop with indices, enumerated(), or for-in over elements instead of hand-rolled ranges that overshoot
- Guard isEmpty before first!, last!, or removeFirst(), since those trap on empty arrays
- Add a safe subscript returning an optional and unwrap it, so short payloads become handled nils
Imagine a row of five numbered lockers, 0 through 4. Asking for locker 5 isn't an empty locker, it's the wall. Swift doesn't hand you the wall politely; it sounds the alarm. A safe subscript knocks first: if there's no locker with that number, it reports back instead of crashing through the plaster. Counting the lockers first is the count check, and both habits beat patching plaster.
It's the shortest crash in Swift: Fatal error: Index out of range. One line, one subscript, one array slightly shorter than you believed. The feature worked in every demo because the demo data always had five items, and the first user with two items crashed on launch.
Arrays feel safe because they're simple. You built the list, you counted the items, you wrote users[0] with confidence. Then a server sent fewer elements, a filter removed rows, or a loop ran one step too far, and confidence became a crash report. There's no partial failure here: the subscript either lands or the app dies.
Beginners meet this in four familiar places: loops using a closed range, first! on an empty list, table row numbers used as array indices, and hardcoded positions in server payloads. Each is a story about an index that was valid when written and wrong when run.
This guide teaches you to read the crash, fix it with count checks and safe subscripts, and restructure loops and lookups so the assumption disappears. You'll trade a little subscript convenience for lists that survive real data.
Valid Indices End at Count Minus One
A Swift array with count elements owns exactly the indices 0 through count minus 1. There are no exceptions, no auto-growing reads, no nil for missing slots. Any subscript outside that range traps and terminates the app with index out of range. Empty arrays own no indices at all, so even index 0 crashes.
This strictness surprises developers coming from dictionaries, where a missing key returns nil. Subscripts look identical but behave oppositely: dictionary lookup is failable, array subscript is a demand. When you write users[i], you're asserting the element exists, and Swift enforces the assertion ruthlessly.
The debugger makes diagnosis quick. The crash frame shows the index value and the array's count side by side. Index equal to count means off-by-one. Index wildly large means a row number or server position was used raw. Count of zero means an empty list met a force-read. Read those two numbers before touching code.
The habit that prevents most crashes is checking membership with indices.contains(i) before subscripting computed positions, and guarding isEmpty before first, last, or removals. These checks are cheap, readable, and honest: they admit the array might be shorter than hoped and say what the UI should do instead.
indices.contains() for computed positions and isEmpty for first and last.Off-by-One Loops and Ranges That Overshoot
Hand-rolled index loops are the top source of this crash. Writing 0...array.count with a closed range visits index count on the final pass, one past the end. The half-open 0..<array.count is correct, but both forms are invitations to drift: any later edit to the bounds reintroduces the bug silently.
Prefer loops that can't overshoot. Iterating users.indices derives the range from the array itself, so deletions and filters move the bounds automatically. enumerated() pairs each index with its element for displays that need numbering. Direct for-in over elements removes indices entirely when you don't need positions.
Higher-order functions extend the same safety. map, filter, and forEach visit exactly the elements present, with no index arithmetic to get wrong. When you need neighbors or windows, use zip with a dropped first element or stride over indices, both of which stay inside bounds by construction.
Mutation during iteration deserves special care. Removing elements inside a forward index loop shifts everything after the removal, skipping items or overshooting the shrunken end. Filter into a new array, iterate indices reversed for removals, or collect doomed indices first and delete after the loop. The crash you avoid is the loop that worked until the data changed shape.
enumerated(), or the elements themselves so bounds track the data.Empty Arrays and the first! and last! Trap
first!, last!, and removeFirst() on an empty array trap immediately, and empty arrays are everywhere in production: fresh installs, cleared caches, filters matching nothing, searches with no results. Testers with seeded accounts never see these states, so the crash ships to exactly the users least equipped to forgive it.
The safe forms are already in the standard library. Optional first and last return nil instead of trapping, which flows into guard let or ?? and lands naturally on empty-state UI. isEmpty checks before removeFirst() and removeLast() convert removals into no-ops with a message. These aren't workarounds; they're the intended API for lists that legitimately run dry.
Design the empty state deliberately. A leaderboard with no players, an inbox with no mail, a cart with no items: each deserves copy, illustration, and a next action. When designers own these screens, the guard you write becomes a feature instead of a patch, and QA starts testing emptiness as a first-class path.
Search and filter paths need the same treatment twice: once for the source list and once for the result. A non-empty library can still produce an empty search, and subscripting results[0] to highlight the top hit crashes on every miss. Unwrap the optional first of the filtered array and render the no-results view with the same care as the results.
The Safe Subscript Extension That Ends Read Crashes
A safe subscript extension converts out-of-range reads from crashes into nils you can handle. The implementation is five lines: check indices.contains(index), return the element when valid, nil otherwise. Call sites use players[safe: 2] with guard let, and short payloads flow into fallback UI instead of crash reports. Adopt it everywhere you read computed or server-driven positions.
Know its limits honestly. Safe subscripts cover reads only. Writes like players[safe: 2] = x can't work through an optional return, and mutating methods like remove(at:) still trap on bad indices. Guard those separately with contains checks before mutating. The extension removes one crash class, not all of them.
Pair the extension with a team convention: plain [] is allowed only where the index is provably valid, such as inside a loop over indices or after an explicit contains check. Every other read uses [safe:]. Code review then becomes mechanical: a bare subscript with a computed index is a reject until proven safe.
Log the misses that matter. When a leaderboard payload arrives shorter than the UI expects, the guard should record the expected versus actual count. Safe reads keep the app alive; the log entry gets the backend contract fixed. Survival without visibility is just a slower way to rot.
contains() before remove(at:) and subscript assignment.Table Rows Are Not Array Indices
Table rows and array indices live in different worlds. Row numbers count visible table positions including section headers, loading cells, and offsets. Array indices count model elements. Using one as the other works until the table gains a header, a second section, or a skeleton row, then every mapping shifts and subscripts land past the end.
Make the mapping explicit. Compute the model index from the index path with documented offsets, guard it with indices.contains, and handle the miss. Better still, sidestep arithmetic by looking models up with first(where:) on a stable id. Ids survive sorting, filtering, and sectioning; positions don't.
Diffable data sources help by binding models to index paths through snapshots instead of parallel arrays. When the snapshot and the array disagree, though, the same crash returns, so keep a single source of truth. Derive the table from the array or the array from the snapshot, never both independently.
Selection callbacks deserve the same care as cell configuration. didSelectRowAt fires after deletions and refreshes have reshaped the array, so an index captured at display time may be stale at tap time. Re-resolve the model inside the callback from current data, and ignore taps that no longer map. A tap that does nothing beats a tap that crashes.
Stale Indices After Deletes, Refreshes, and Awaits
Indices go stale the moment the array changes. A delete shrinks the count, a refresh replaces the contents, a background sync reorders everything, and any index captured earlier now points elsewhere or nowhere. Code that stores an index across these events is holding a ticket for a train that already left.
Async code multiplies the risk. An index captured before a network request may be invalid when the completion handler runs, because the user deleted rows or pulled to refresh mid-flight. The fix is snapshotting: capture let snapshot = items before the call and work with the immutable copy inside the closure, or re-validate the index against current data on return. Never trust a pre-await index after an await.
Prefer identity over position for anything that outlives the current run loop. Pass model ids between screens, not row numbers. Store selected ids in sets, not index arrays. When the detail screen needs its model, fetch by id from the current source. Positions are ephemeral; identities persist.
Deletion flows need ordered discipline. Delete from the model first, then update the table with the same index path, inside one consistent batch. Compute dependent indices after each mutation rather than caching them upfront. When several mutations chain, re-derive every index from the latest state. The code reads slightly longer and crashes dramatically less.
The Two-Player Match That Broke the Leaderboard
- Test lists at every size that production allows: zero, one, boundary, and large. Fixtures with comfortable data hide the crashes small data causes.
- Never trust fixed positions in payloads you don't control. Bounds-check or look up by identity instead of assuming length.
- Empty states are features, not afterthoughts. Design them before release and the guard you write becomes UI instead of a crash.
enumerated() and rerun the same data.| File | Command / Code | Purpose |
|---|---|---|
| BoundsCheck.swift | let scores = [10, 20] | Valid Indices End at Count Minus One |
| LoopPatterns.swift | let users = ["Ada", "Grace", "Katherine"] | Off-by-One Loops and Ranges That Overshoot |
| EmptyState.swift | func headline(from titles: [String]) -> String { | Empty Arrays and the first! and last! Trap |
| SafeSubscript.swift | extension Array { | The Safe Subscript Extension That Ends Read Crashes |
| RowMapping.swift | func player(forRow row: Int, players: [String], sectionOffset: Int = 1) -> Strin... | Table Rows Are Not Array Indices |
Key takeaways
Common mistakes to avoid
5 patternsLooping with 0...count instead of 0..<count
enumerated(), or iterate the elements directly with for user in users. The range always matches the array, even after deletions.Calling first!, last!, or removeFirst() on an empty array
Using a table row number directly as an array index
Holding an index across an async network callback
Subscripting server-driven positions without bounds checks
Interview Questions on This Topic
Which indices are valid for a Swift array?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Swift. Mark it forged?
5 min read · try the examples if you haven't