2026-09-27
Swift Array Mistakes That Compile Fine, and the Fixes
Six Swift Array mistakes that compile cleanly but crash or quietly misbehave, and five standard-library tools that replace hand-written loops.
for var reminder in reminders { reminder.isDone = true } compiles, runs, and changes nothing. Swift’s Array is a value type with a small, sharp API, and most Array bugs come from forgetting one of those two facts. None of the six mistakes below gets a compiler warning. Three of them crash.
Six mistakes
Mutating a copy in for var
Reminder is a struct, so each loop iteration gets its own copy. The copy is updated and thrown away.
// The loop variable is a copy; the array never sees the change
for var reminder in reminders {
reminder.isDone = true
}
Write through the array’s own storage instead:
// Mutate the elements in place, by index
for index in reminders.indices {
reminders[index].isDone = true
}
Filling an array with one shared instance
Array(repeating:count:) evaluates its argument once. With a class, every slot holds the same object.
// Three slots, one ImageLoader: loaders[0] === loaders[2]
let loaders = Array(repeating: ImageLoader(), count: 3)
Build distinct instances with map instead:
// Three independent loaders
let loaders = (0..<3).map { _ in ImageLoader() }
Removing elements while you iterate
ids.indices is evaluated once, before the loop starts. Every removal shrinks the array under it, and the loop walks off the end.
// Traps: Fatal error: Index out of range
for index in ids.indices where ids[index].isMultiple(of: 2) {
ids.remove(at: index)
}
removeAll(where:) does it in one pass, and cannot go out of bounds:
// One pass, no index bookkeeping
ids.removeAll { $0.isMultiple(of: 2) }
In a release build the trap has no message. It is just a crash.
Treating a slice as zero-based
An ArraySlice keeps its parent’s indices. scores[2...] starts at index 2, so subscripting it with 0 traps.
// Traps: Fatal error: Index out of bounds
let scores = [10, 20, 30, 40, 50]
let latest = scores[2...]
let top = latest[0]
Use first, latest[latest.startIndex], or Array(latest) when you genuinely need zero-based indices. The same trap hides in enumerated(): it produces offsets counting from zero, not indices, so latest[offset] fails the same way.
Scanning the whole array to find one element
filter visits every element and builds a new array. Only then does .first take one value from it. On a million elements whose match is the eleventh, that is a million predicate calls for one result.
// Visits all 1,000,000 elements, allocates an array, keeps one
let firstLarge = values.filter { $0 > 10 }.first
first(where:) stops at the first match — eleven calls:
// Stops as soon as the predicate returns true
let firstLarge = values.first { $0 > 10 }
The same waste shows up as contains inside a loop, which turns an O(n) job into O(n × m). When you check membership repeatedly, build a Set once and ask it instead.
Building a dictionary that traps on duplicates
Dictionary(uniqueKeysWithValues:) means what it says. Give it a duplicate key and it crashes rather than choosing a winner.
// Traps: Fatal error: Duplicate values for key: 'a'
let flags = Dictionary(uniqueKeysWithValues: [("a", 1), ("b", 2), ("a", 3)])
If the data can repeat a key — and data from a server can — say which value wins:
// Later values replace earlier ones
let flags = Dictionary(pairs, uniquingKeysWith: { _, latest in latest })
Five tips
Build collections with reduce(into:)
reduce([]) { $0 + [$1] } creates a new array on every step, which makes it quadratic: four times the input costs far more than four times the time. reduce(into:) hands you the accumulator as inout and appends in place.
// Quadratic: a fresh array per element
let doubled = values.reduce([Int]()) { $0 + [$1 * 2] }
// Linear: one array, mutated in place
let doubled = values.reduce(into: [Int]()) { $0.append($1 * 2) }
Group in one call with Dictionary(grouping:by:)
Grouping is a loop, a dictionary, and a default-value dance, or it is one initializer:
// Contacts keyed by the first letter of their last name
let sections = Dictionary(grouping: contacts, by: { $0.lastName.prefix(1) })
Sort by several keys with a tuple
Tuples of up to six Comparable elements are themselves comparable, left to right. That is a multi-key sort with no nested if:
// Last name first, then first name to break ties
let sorted = contacts.sorted {
($0.lastName, $0.firstName) < ($1.lastName, $1.firstName)
}
Go lazy when you need only part of a chain
lazy turns filter and map into views that run one element at a time, on demand. Paired with first, the whole chain stops the moment it has an answer:
// Runs the filter until one even number appears; maps only that one
let firstEvenScaled = values.lazy.filter { $0.isMultiple(of: 2) }.map { $0 * 10 }.first
Do not reach for it by default. A lazy chain re-runs its closures each time you iterate it, so a result you read more than once belongs in a real array.
Check bounds with indices.contains
Before subscripting with an index from outside — a saved position, a row from a table view — check it against the array’s own range. For an Array that check is constant time:
// Safe to subscript only when the index is in range
if reminders.indices.contains(savedIndex) {
open(reminders[savedIndex])
}
It works on slices too, because it asks the collection rather than assuming its range starts at zero.
Your turn
Which of these has actually crashed an app of yours — and did it crash in development, or only once a release build met real data? If you think one of the tips is wrong for your codebase, say so. Send it through the contact page; I read everything.
Keep going
Arrays are values, so an array of Sendable elements can cross actors safely. What to do when the elements are not Sendable is where Swift Concurrency in production picks up.