A deep dive into Collections, Sequences, and Iterators in Swift

Published on: November 5, 2025

When you write for item in list the compiler quietly sets a lot of machinery in motion. Usually writing a for loop is a pretty mundane task, it's not that complex of a syntax to write. However, it's always fun to dig a bit deeper and see what happens under the hood. In this post I’ll unpack the pieces that make iteration tick so you can reason about loops with the same confidence you already have around optionals, enums, or result builders.

Here’s what you’ll pick up:

  • What Sequence and Collection promise—and why iterators are almost always structs.
  • How for … in desugars, plus the pitfalls of mutating while you loop.
  • How async iteration and custom collections extend the same core ideas.

Understanding Sequence

Sequence is the smallest unit of iteration in Swift and it comes with a very intentional contract: "when somebody asks for an iterator, give them one that can hand out elements until you’re out". That means a conforming type needs to define two associated types (Element and Iterator) and return a fresh iterator every time makeIterator() is called.

public protocol Sequence {
    associatedtype Element
    associatedtype Iterator: IteratorProtocol where Iterator.Element == Element

    func makeIterator() -> Iterator
}

The iterator itself conforms to IteratorProtocol and exposes a mutating next() function:

public protocol IteratorProtocol {
    associatedtype Element
    mutating func next() -> Element?
}

You’ll see most iterators implemented as structs. next() is marked mutating, so a value-type iterator can update its position without any extra ceremony. When you copy the iterator, you get a fresh cursor that resumes from the same point, which keeps iteration predictable and prevents shared mutable state from leaking between loops. Classes can adopt IteratorProtocol too, but value semantics are a natural fit for the contract.

There are two important implications to keep in mind:

  • A sequence only has to be single-pass. It’s perfectly valid to hand out a "consumable" iterator that can be used once and then returns nil forever. Lazy I/O streams or generator-style APIs lean on this behaviour.
  • makeIterator() should produce a fresh iterator each time you call it. Some sequences choose to store and reuse an iterator internally, but the contract encourages the "new iterator per loop" model so for loops can run independently without odd interactions.

If you’ve ever used stride(from:to:by:) you’ve already worked with a plain Sequence. The standard library exposes it right next to ranges, and it’s perfect for walking an arithmetic progression without allocating an array. For example:

for angle in stride(from: 0, through: 360, by: 30) {
    print(angle)
}

This prints 0, 30, 60 … 360 and then the iterator is done. If you ask for another iterator you’ll get a new run, but there’s no requirement that the original one resets itself or that the sequence stores all of its values. It just keeps the current step and hands out the next number until it reaches the end. That’s the core Sequence contract in action.

So to summarize, a Sequence contains n items (we don't know how many because there's no concept of count in a Sequence), and we can ask the Sequence for an Iterator to receive items until the Sequence runs out. As you saw with stride, the Sequence doesn't have to hold all values it will send in memory. It can generate the values every time its Iterator has its next() function called.

If you need multiple passes, random access, or counting, Sequence won’t give you that by itself. The protocol doesn’t forbid throwing the elements away after the first pass; AsyncStream-style sequences do exactly that. An AsyncStream will vend a new value to an async loop, and then it discards the value forever.

In other words, the only promise is "I can vend an iterator". Nothing says the iterator can be rewound or that calling makeIterator() twice produces the same results. That’s where Collection steps in.

Collection’s Extra Guarantees

Collection refines Sequence with the promises we lean on day-to-day: you can iterate as many times as you like, the order is stable (as long as the collection’s own documentation says so), and you get indexes, subscripts, and counts. Swift's Array, Dictionary, and Set all conform to the Collection protocol for example.

public protocol Collection: Sequence {
    associatedtype Index: Comparable

    var startIndex: Index { get }
    var endIndex: Index { get }

    func index(after i: Index) -> Index
    subscript(position: Index) -> Element { get }
}

These extra requirements unlock optimisations. map can preallocate exactly the right amount of storage. count doesn’t need to walk the entire data set. If a Collection also implements BidirectionalCollection or RandomAccessCollection the compiler can apply even more optimizations for free.

Worth noting: Set and Dictionary both conform to Collection even though their order can change after you mutate them. The protocols don’t promise order, so if iteration order matters to you make sure you pick a type that documents how it behaves.

How for … in Actually Works

Now that you know a bit more about collections and iterating them in Swift, here’s what a simple loop looks like if you were to write one without using for x in y:

var iterator = container.makeIterator()
while let element = iterator.next() {
    print(element)
}

To make this concrete, here’s a small custom sequence that will count down from a given starting number:

struct Countdown: Sequence {
    let start: Int

    func makeIterator() -> Iterator {
        Iterator(current: start)
    }

    struct Iterator: IteratorProtocol {
        var current: Int

        mutating func next() -> Int? {
            guard current >= 0 else { return nil }
            defer { current -= 1 }
            return current
        }
    }
}

Running for number in Countdown(start: 3) executes the desugared loop above. Copy the iterator halfway through and each copy continues independently thanks to value semantics.

One thing to avoid: mutating the underlying storage while you’re in the middle of iterating it. An array iterator assumes the buffer stays stable; if you remove an element, the buffer shifts and the iterator no longer knows where the next element lives, so the runtime traps with Collection modified while enumerating. When you need to cull items, there are safer approaches: call removeAll(where:) which handles the iteration for you, capture the indexes first and mutate after the loop, or build a filtered copy and replace the original once you’re done.

Here’s what a real bug looks like. Imagine a list of tasks where you want to strip the completed ones:

struct TodoItem {
    var title: String
    var isCompleted: Bool
}

var todoItems = [
    TodoItem(title: "Ship blog post", isCompleted: true),
    TodoItem(title: "Record podcast", isCompleted: false),
    TodoItem(title: "Review PR", isCompleted: true),
]

for item in todoItems {
    if item.isCompleted,
       let index = todoItems.firstIndex(where: { $0.title == item.title }) {
        todoItems.remove(at: index) // ⚠️ Fatal error: Collection modified while enumerating.
    }
}

Running this code crashes the moment the first completed task is removed because the iterator still expects the old layout. It also calls firstIndex on every pass, so each iteration scans the whole array again—an easy way to turn a quick cleanup into O(n²) work. A safer rewrite delegates the traversal:

todoItems.removeAll(where: \.isCompleted)

Because removeAll(where:) owns the traversal, it walks the array once and removes matches in place.

If you prefer to keep the originals around, build a filtered copy instead:

let openTodos = todoItems.filter { !$0.isCompleted }

Both approaches keep iteration and mutation separated, which means you won’t trip over the iterator mid-loop. Everything we’ve looked at so far assumes the elements are ready the moment you ask for them. In modern apps, it's not uncommon to want to iterate over collections (or streams) that generate new values over time. Swift’s concurrency features extend the exact same iteration patterns into that world.

Async Iteration in Practice

Swift Concurrency introduces AsyncSequence and AsyncIteratorProtocol. These look familiar, but the iterator’s next() method can suspend and throw.

public protocol AsyncSequence {
    associatedtype Element
    associatedtype AsyncIterator: AsyncIteratorProtocol where AsyncIterator.Element == Element

    func makeAsyncIterator() -> AsyncIterator
}

public protocol AsyncIteratorProtocol {
    associatedtype Element
    mutating func next() async throws -> Element?
}

You consume async sequences with for await:

for await element in stream {
    print(element)
}

Under the hood the compiler builds a looping task that repeatedly awaits next(). If next() can throw, switch to for try await. Errors propagate just like they would in any other async context.

Most callback-style APIs can be bridged with AsyncStream. Here’s a condensed example that publishes progress updates:

func makeProgressStream() -> AsyncStream<Double> {
    AsyncStream { continuation in
        let token = progressManager.observe { fraction in
            continuation.yield(fraction)
            if fraction == 1 { continuation.finish() }
        }

        continuation.onTermination = { _ in
            progressManager.removeObserver(token)
        }
    }
}

for await fraction in makeProgressStream() now suspends between values. Don’t forget to call finish() when you’re done producing output, otherwise downstream loops never exit.

Since async loops run inside tasks, they should play nicely with cancellation. The easiest pattern is to check for cancellation inside next():

struct PollingIterator: AsyncIteratorProtocol {
    mutating func next() async throws -> Item? {
        try Task.checkCancellation()
        return await fetchNextItem()
    }
}

If the task is cancelled you’ll see CancellationError, which ends the loop automatically unless you decide to catch it.

Implementing your own collections

Most of us never have to build a collection from scratch—and that’s a good thing. Arrays, dictionaries, and sets already cover the majority of cases with battle-tested semantics. When you do roll your own, tread carefully: you’re promising index validity, multi-pass iteration, performance characteristics, and all the other traits that callers expect from the standard library. A tiny mistake can corrupt indices or put you in undefined territory.

Still, there are legitimate reasons to create a specialised collection. You might want a ring buffer that overwrites old entries, or a sliding window that exposes just enough data for a streaming algorithm. Whenever you go down this path, keep the surface area tight, document the invariants, and write exhaustive tests to prove the collection acts like a standard one.

Even so, it's worth exploring a custom implementation of Collection for the sake of studying it. Here’s a lightweight ring buffer that conforms to Collection:

struct RingBuffer<Element>: Collection {
    private var storage: [Element?]
    private var head = 0
    private var tail = 0
    private(set) var count = 0

    init(capacity: Int) {
        storage = Array(repeating: nil, count: capacity)
    }

    mutating func enqueue(_ element: Element) {
        storage[tail] = element
        tail = (tail + 1) % storage.count
        if count == storage.count {
            head = (head + 1) % storage.count
        } else {
            count += 1
        }
    }

    // MARK: Collection
    typealias Index = Int

    var startIndex: Int { 0 }
    var endIndex: Int { count }

    func index(after i: Int) -> Int {
        precondition(i < endIndex, "Cannot advance past endIndex")
        return i + 1
    }

    subscript(position: Int) -> Element {
        precondition((0..<count).contains(position), "Index out of bounds")
        let actual = (head + position) % storage.count
        return storage[actual]!
    }
}

A few details in that snippet are worth highlighting:

  • storage stores optionals so the buffer can keep a fixed capacity while tracking empty slots. head and tail advance as you enqueue, but the array never reallocates.
  • count is maintained separately. A ring buffer might be partially filled, so relying on storage.count would lie about how many elements are actually available.
  • index(after:) and the subscript accept logical indexes (0 through count) and translate them to the right slot in storage by offsetting from head and wrapping with the modulo operator. That bookkeeping keeps iteration stable even after the buffer wraps around.
  • Each accessor defends the invariants with precondition. Skip those checks and a stray index can pull stale data or walk off the end without warning.

Even in an example as small as the one above, you can see how much responsibility you take on once you adopt Collection.

In Summary

Iteration looks simple because Swift hides the boilerplate, but there’s a surprisingly rich protocol hierarchy behind every loop. Once you know how Sequence, Collection, and their async siblings interact, you can build data structures that feel natural in Swift, reason about performance, and bridge legacy callbacks into clean async code.

If you want to keep exploring after this, revisit the posts I’ve written on actors and data races to see how iteration interacts with isolation. Or take another look at my pieces on map and flatMap to dig deeper into lazy sequences and functional pipelines. Either way, the next time you reach for for item in list, you’ll know exactly what’s happening under the hood and how to choose the right approach for the job.

Categories

Swift

Expand your learning with my books

Practical Core Data header image

Learn everything you need to know about Core Data and how you can use it in your projects with Practical Core Data. It contains:

  • Twelve chapters worth of content.
  • Sample projects for both SwiftUI and UIKit.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more