Home › Mobile › Swift Index Out of Range: Bounds Checks That Work
Beginner 5 min · September 23, 2026

Swift Index Out of Range: Bounds Checks That Work

Check count, guard empty lists, and add a safe subscript.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 9 min
  • ✓Basic Swift arrays and loops
  • ✓Table views or simple lists
  • ✓Optionals and guard let basics
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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
✦ Definition~90s read
What is Swift Index Out of Range in Array Access?

Swift arrays are contiguous, zero-indexed collections: the first element sits at index 0 and the last at index count minus 1. Subscripting asserts that position exists, and Swift enforces the assertion with a runtime trap when it doesn't. There is no optional chaining for plain subscripts, no auto-vivification, no best-effort nil.

★
Imagine a row of five numbered lockers, 0 through 4.

The subscript is a contract, and index out of range is the penalty clause.

Four patterns break that contract in practice. Off-by-one loops visit index count through closed ranges. Force-reads like first! and removeFirst() assume non-emptiness that fresh installs and filters disprove. Row numbers masquerade as model indices until headers and sections shift the mapping.

Hardcoded positions assume server payloads always arrive full length. Each pattern works on comfortable fixtures and fails on real data.

Swift's safety tools address each pattern directly. Loops over indices, enumerated(), and the elements themselves can't overshoot. Optional first and last plus isEmpty guards make emptiness a handled state. A safe subscript extension turns computed reads into failable lookups. Identity-based fetching with first(where:) replaces fragile position tracking across screens and async boundaries.

The mindset shift is treating every index as a claim about current data that expires on mutation. Check computed positions, re-validate after awaits and deletions, and pass ids instead of positions across lifetimes. Arrays stay simple; the discipline around them is what keeps the app alive.

Plain-English First

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.

BoundsCheck.swiftSWIFT
1
2
3
4
5
6
7
8
9
10
11
12
13
let scores = [10, 20]

// Crash: valid indices are 0 and 1 only.
// let third = scores[2]

// Safe: check membership first.
if scores.indices.contains(2) {
    print(scores[2])
} else {
    print("No third score yet")
}

print(scores.count) // 2, so the last valid index is 1.
📊 Production Insight
A team subscripted podium slots directly because fixtures always held full rosters. The first two-player production match crashed on open. Rule: test every list screen with zero, one, and boundary-sized data before release.
🎯 Key Takeaway
Arrays own 0 through count minus 1 and nothing else. Check 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.

LoopPatterns.swiftSWIFT
1
2
3
4
5
6
7
8
9
10
11
12
13
let users = ["Ada", "Grace", "Katherine"]

// Wrong: closed range visits index 3, which is out of bounds.
// for i in 0...users.count { print(users[i]) }

// Right: half-open range, or better, the array's own indices.
for i in users.indices {
    print(users[i])
}

for (index, user) in users.enumerated() {
    print("\(index): \(user)")
}
📊 Production Insight
A closed-range loop passed review because fixtures never emptied the array. Rule: ban hand-rolled ranges over count in review; require indices or for-in.
🎯 Key Takeaway
Closed ranges over count crash on the last pass. Loop over indices, 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.

EmptyState.swiftSWIFT
1
2
3
4
5
6
7
8
9
10
11
func headline(from titles: [String]) -> String {
    guard let first = titles.first else {
        return "No stories yet"
    }
    return first
}

var queue = [String]()
if !queue.isEmpty {
    queue.removeFirst()
}
⚠ Empty Lists Break Every Force-Read
first! and last! are promises the list is non-empty. On fresh installs and filtered views that promise breaks daily. Use optional first and last with a guard instead.
📊 Production Insight
Fresh-install crashes outnumbered all others because testers always had seeded data. Rule: test every list screen with a brand-new empty account.
🎯 Key Takeaway
Empty is a normal state, not an error. Use optional first and last with guards, and design empty screens worth showing.

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.

SafeSubscript.swiftSWIFT
1
2
3
4
5
6
7
8
9
10
11
12
extension Array {
    subscript(safe index: Index) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

func medal(for players: [String], at position: Int) -> String {
    guard let name = players[safe: position] else {
        return "No player in this slot"
    }
    return name
}
📊 Production Insight
After adopting safe subscripts, a team's remaining index crashes were all writes, which reads can't cover. Rule: guard contains() before remove(at:) and subscript assignment.
🎯 Key Takeaway
A five-line [safe:] subscript turns bad reads into handled nils. Reserve plain [] for provably valid indices and log the misses.

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.

RowMapping.swiftSWIFT
1
2
3
4
5
6
7
8
9
func player(forRow row: Int, players: [String], sectionOffset: Int = 1) -> String? {
    let index = row - sectionOffset
    guard players.indices.contains(index) else { return nil }
    return players[index]
}

func player(byID id: String, players: [Player]) -> Player? {
    players.first(where: { $0.id == id })
}
📊 Production Insight
Adding one section header shifted every row mapping and crashed the top cell. Rule: look models up by id so layout edits can't move data.
🎯 Key Takeaway
Rows count visible positions; indices count models. Map explicitly with guards, or look up by id so layout changes can't crash you.

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.

📊 Production Insight
A delete-then-tap race crashed because the index outlived the array it indexed. Rule: pass ids between screens and re-resolve inside callbacks.
🎯 Key Takeaway
Indices expire on mutation and await. Snapshot arrays for closures, pass ids between screens, and re-derive positions from current data.
● Production incidentPOST-MORTEMseverity: high

The Two-Player Match That Broke the Leaderboard

Symptom
Crashes clustered on the leaderboard screen within an hour of a tournament feature launch. Every report showed Fatal error: Index out of range. Affected matches all had one or two players; full matches rendered perfectly.
Assumption
The team assumed the leaderboard always carried at least three players because every staging match had dozens. The client read positions 0 through 2 directly, and QA never played a match with fewer than ten people. Empty and tiny leaderboards were deemed impossible.
Root cause
The leaderboard view read players[0], players[1], and players[2] to render podium slots. Production matches with fewer than three players produced shorter arrays, and the subscript for the missing slot trapped. Staging fixtures always contained full rosters, so no test ever exercised a short board.
Fix
The hotfix added count guards and a safe subscript around every leaderboard read, with a friendly empty state for short boards. The team added backend contract tests for 0, 1, and 2 player payloads and seeded QA accounts that stay permanently small.
Key lesson
  • 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.
Production debug guideFive checks that turn a dead subscript into a handled edge case.5 entries
Symptom · 01
Crash on a subscript line with a numeric index
→
Fix
Open the crash frame, note the index value and the array's count in the variables view. If index equals count, it's an off-by-one loop; rewrite it with indices or enumerated() and rerun the same data.
Symptom · 02
Crash on first or last with new or cleared accounts
→
Fix
Reproduce with an empty account: fresh install, cleared data, or a filter matching nothing. If the crash fires, wrap the first!, last!, or removeFirst() call in an isEmpty guard with an empty-state UI.
Symptom · 03
Crash in cellForRowAt or didSelectRowAt on scroll or tap
→
Fix
Print the index path and the array count at the crash site. If rows outnumber elements, compute the offset explicitly or fetch the model by id with first(where:) instead of subscripting by row.
Symptom · 04
Crash on a hardcoded position in server data
→
Fix
Log the payload count before subscripting fixed positions like items[2]. If production payloads are shorter than fixtures, add a count guard or a safe subscript and handle the short case explicitly.
Symptom · 05
Crash after deleting rows or during background refreshes
→
Fix
Audit every stored indexPath.row and captured index used inside closures. Snapshot the array before async calls, re-validate after awaits, and cancel callbacks when the view dismisses.
Index Out of Range Causes Compared
Root CauseHow to ConfirmFixPrevention
Off-by-one loop using 0...countCrash on the final loop iteration; index equals array count in debuggerLoop over indices or use enumerated() so the range can't overshootPrefer for-in over elements; lint for closed ranges over count
first! or last! on an empty arrayCrash with an empty array in the variables view; count is 0Guard isEmpty first or use optional first and last without !Show empty states; test every list screen with zero items
Stale index after deletion or refreshCrash follows a delete, filter, or reload; index exceeds the new countRecompute the index after mutation or look up by id insteadSnapshot arrays before async work; cancel callbacks on dismiss
Server payload shorter than assumedCrash on a hardcoded position like items[2]; payload has fewer elementsBounds-check or safe-subscript before reading fixed positionsDecode with validated models; never trust fixed positions in payloads
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
BoundsCheck.swiftlet scores = [10, 20]Valid Indices End at Count Minus One
LoopPatterns.swiftlet users = ["Ada", "Grace", "Katherine"]Off-by-One Loops and Ranges That Overshoot
EmptyState.swiftfunc headline(from titles: [String]) -> String {Empty Arrays and the first! and last! Trap
SafeSubscript.swiftextension Array {The Safe Subscript Extension That Ends Read Crashes
RowMapping.swiftfunc player(forRow row: Int, players: [String], sectionOffset: Int = 1) -> Strin...Table Rows Are Not Array Indices

Key takeaways

1
Valid indices run 0 to count minus 1; index count always crashes.
2
Loop over indices or elements, never with a closed range over count.
3
Guard isEmpty before first!, last!, or removeFirst().
4
Add a safe subscript so out-of-range reads become handled nils.
5
Map table rows to models explicitly; never use row numbers raw.
6
Re-validate indices after mutations, refreshes, and awaits.

Common mistakes to avoid

5 patterns
×

Looping with 0...count instead of 0..<count

Symptom
Crash on the last iteration of every loop over a non-empty array. The final index equals count, which is one past the end.
Fix
Loop with indices, users.indices, or 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

Symptom
Crash on fresh installs or cleared accounts where the list is legitimately empty. Works for every tester with seeded data.
Fix
Guard with isEmpty before first, last, removeFirst, or removeLast. Show an empty state or return early when there's nothing to display.
×

Using a table row number directly as an array index

Symptom
Crash when sections, headers, or loading rows shift the mapping. Row 5 of the table isn't element 5 of the array.
Fix
Subtract the offset before subscripting, and guard the adjusted index. Better, look up the model by id with first(where:) so row math can't drift.
×

Holding an index across an async network callback

Symptom
Crash when the user deletes a row or refreshes while a request is in flight. The index was valid when captured and stale when used.
Fix
Capture a snapshot of the array before the async block, or re-validate the index on the main thread before using it. Cancel stale callbacks when the screen dismisses.
×

Subscripting server-driven positions without bounds checks

Symptom
Sporadic crashes on payloads shorter than expected. The server sends two items where the client assumes five.
Fix
Add a safe subscript extension returning an optional, and use guard let at the call site. Keep plain [] only where the index is provably valid, like inside indices loops.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Which indices are valid for a Swift array?
Q02JUNIOR
What off-by-one error crashes array loops?
Q03SENIOR
How does a safe subscript extension work?
Q04SENIOR
Why can't a table row number be used as an array index directly?
Q05SENIOR
Why do stored indices go stale across mutations and async calls?
Q01 of 05JUNIOR

Which indices are valid for a Swift array?

ANSWER
Swift arrays are zero-indexed with valid indices from 0 to count minus 1. Reading users[count] or any negative or past-the-end index traps at runtime and crashes with index out of range.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is index count valid when the array has count elements?
02
How do I read the first or last element safely?
03
Does a safe subscript solve everything?
04
What's safer than storing an index across screens?
05
Why did removeFirst() crash when the list looked fine?
06
Can async code invalidate my index?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Notes here come from systems that actually shipped.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Swift. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Swift Unexpectedly Found Nil While Unwrapping an Optional
2 / 3 · Swift
Next
Swift Publishing Changes From Background Threads Not Allowed
→