Span and MutableSpan: Safe Contiguous Memory Access in Swift


You have spent years choosing between UnsafeBufferPointer for speed and Array for safety, accepting that contiguous memory access always demands one compromise or the other. Swift 6.2 eliminates that trade-off. Span and MutableSpan give you bounds-checked, zero-cost views into contiguous memory, and the compiler enforces their lifetime so you never dangle a pointer again.

This post covers what Span and MutableSpan are, how they work under the hood with ~Escapable lifetime dependencies, and when you should reach for them instead of slices or unsafe pointers. We will not cover InlineArray in depth — that gets its own dedicated post.

Contents

The Problem

Consider a rendering pipeline that processes frame data stored in a contiguous buffer. You need to pass a view of that data to several processing stages without copying it, and you need bounds safety so a stray index does not corrupt memory.

Before Swift 6.2, your options looked like this:

struct FrameProcessor {
    func processWithArray(_ pixels: [UInt8]) {
        // Safe, but Array's copy-on-write semantics mean every
        // function call may trigger a uniqueness check or even
        // a full copy if the buffer is shared.
        for i in 0..<pixels.count {
            applyGammaCorrection(pixels[i])
        }
    }

    func processWithUnsafePointer(_ buffer: UnsafeBufferPointer<UInt8>) {
        // Fast -- no reference counting, no CoW overhead.
        // But nothing prevents you from reading past the end,
        // and the compiler cannot guarantee the buffer is still alive.
        for i in 0..<buffer.count {
            applyGammaCorrection(buffer[i])
        }
    }
}

Array gives you bounds checking and memory safety but pays for it with retain/release traffic and copy-on-write bookkeeping. UnsafeBufferPointer gives you raw speed but the name starts with “Unsafe” for a reason — the compiler cannot enforce that the underlying memory outlives the pointer, and out-of-bounds reads are undefined behavior.

This is not just a theoretical concern. In high-throughput code paths like audio processing, image pipelines, or game physics, the overhead of reference counting on every function call adds up. And the risk of a dangling UnsafeBufferPointer is a real source of production crashes.

Introducing Span

Span is a non-copyable, non-escapable view over a contiguous region of memory. It provides bounds-checked element access — just like Array — but without reference counting, copy-on-write, or heap allocation. The compiler enforces that a Span cannot outlive the memory it refers to, making it as safe as Array and as fast as UnsafeBufferPointer.

Span was introduced in SE-0447 and is available starting with Swift 6.2.

Here is the same frame processing code rewritten with Span:

func processFrame(_ pixels: Span<UInt8>) {
    // Bounds-checked access, zero reference counting overhead.
    // The compiler guarantees `pixels` is valid for the
    // duration of this function call.
    for i in pixels.indices {
        applyGammaCorrection(pixels[i])
    }
}

// Obtain a Span from an Array
var frameBuffer: [UInt8] = loadFrameData()
frameBuffer.withSpan { span in
    processFrame(span)
}

Several things to note here. Span conforms to ~Escapable, meaning it cannot be stored in a property, returned from a function that outlives its source, or captured in an escaping closure. This is not a convention — it is a compile-time guarantee. The withSpan closure-based API ensures the Span never escapes the scope where the backing memory is valid.

Accessing Elements

Span supports subscript access with bounds checking, just like Array:

func inspectPixels(_ pixels: Span<UInt8>) {
    let first = pixels[0]           // Bounds-checked
    let last = pixels[pixels.count - 1]

    // Iterate directly
    for pixel in pixels {
        print(pixel)
    }

    // Check emptiness and count
    print(pixels.isEmpty)  // false
    print(pixels.count)    // Number of elements
}

If you subscript out of bounds, you get a deterministic trap — not undefined behavior. This is the critical difference from UnsafeBufferPointer, where an out-of-bounds read silently corrupts your process.

Obtaining a Span

Every contiguous collection in the standard library now offers Span access. The primary API is the span property, available when borrowing the collection:

var movieScores: [Double] = [8.1, 9.3, 7.4, 8.8, 9.0]

// The `span` property is available as a borrowing accessor.
// The resulting Span's lifetime is tied to `movieScores`.
let scores: Span<Double> = movieScores.span
processScores(scores)

Note: The span property was introduced alongside the lifetime dependency system in Swift 6.2. On older compilers, use the closure-based withSpan API instead.

You can also get a Span from InlineArray, UnsafeBufferPointer (via Span(unsafeElements:)), and Data:

// From an InlineArray (stack-allocated, fixed-size)
var topFive = InlineArray<5, String>(repeating: "TBD")
topFive[0] = "Toy Story"
topFive[1] = "Finding Nemo"
topFive[2] = "Inside Out"
topFive[3] = "WALL-E"
topFive[4] = "Coco"
let namesSpan: Span<String> = topFive.span

MutableSpan: Write Access Without Copies

MutableSpan is the mutable counterpart to Span. It provides bounds-checked read-write access to contiguous memory, again with zero reference counting overhead and compiler-enforced lifetimes.

MutableSpan was introduced in SE-0467.

func normalizePixels(_ pixels: inout MutableSpan<UInt8>, maxValue: UInt8) {
    // In-place mutation without copying the underlying buffer.
    for i in pixels.indices {
        pixels[i] = UInt8(
            Double(pixels[i]) / Double(maxValue) * 255.0
        )
    }
}

var frameData: [UInt8] = [120, 200, 45, 180, 255]
frameData.withMutableSpan { mutableSpan in
    normalizePixels(&mutableSpan, maxValue: 255)
}
// frameData is now modified in place -- no copy occurred.

The inout parameter on MutableSpan is deliberate. It signals exclusive access at the call site, which the compiler enforces through Swift’s law of exclusivity. Two MutableSpan values cannot alias the same memory at the same time.

Span vs. MutableSpan

The relationship mirrors let vs. var:

TypeAccessExclusivityTypical API Pattern
SpanRead-onlyShared borrowingcollection.span
MutableSpanRead-writeExclusive (inout)collection.withMutableSpan

Use Span when you only need to read. Use MutableSpan when you need to modify elements in place. This mirrors the principle you already follow with value types: default to immutable, opt into mutation explicitly.

Lifetime Dependencies and ~Escapable

The safety guarantee that makes Span different from UnsafeBufferPointer is its non-escapable lifetime. This section explains the mechanism.

What ~Escapable Means

Span conforms to ~Escapable, a constraint introduced in SE-0446. A ~Escapable type cannot outlive the scope that created it. The compiler enforces this by tracking the lifetime dependency between the Span and its source.

// This compiles: the Span is used within the scope of `data`.
func analyzeScores() {
    let data: [Double] = [8.1, 9.3, 7.4]
    let span = data.span
    print(span[0]) // 8.1
}

// This does NOT compile: the Span would escape its source's lifetime.
// func leakySpan() -> Span<Double> {
//     let data: [Double] = [8.1, 9.3, 7.4]
//     return data.span // Error: cannot return a ~Escapable value
// }

The compiler rejects the second function because data is a local variable — its memory is freed when the function returns, which would leave the Span dangling. This is exactly the class of bug that UnsafeBufferPointer cannot prevent.

Lifetime Dependencies on Function Parameters

When a function receives an Array and wants to return a Span derived from it, you express the dependency using the @lifetime attribute:

// The returned Span's lifetime depends on `source`.
// Callers must keep `source` alive as long as the Span is in use.
@lifetime(source)
func firstHalf(of source: borrowing [Double]) -> Span<Double> {
    let mid = source.count / 2
    return source.span.extracting(0..<mid)
}

The @lifetime(source) annotation tells the compiler that the returned value must not outlive source. If the caller drops the array while still holding the span, the compiler emits an error.

Warning: @lifetime annotations are not optional sugar. Without them, the compiler has no way to verify that a returned Span is safe. If you write functions that produce Span values from borrowed containers, you must annotate the dependency.

Advanced Usage

Extracting Sub-Spans

Span supports slicing through the extracting method, which returns a new Span over a sub-range of the original:

func processRenderPassBatches(_ allPixels: Span<UInt8>, batchSize: Int) {
    var offset = 0
    while offset < allPixels.count {
        let end = min(offset + batchSize, allPixels.count)
        let batch = allPixels.extracting(offset..<end)
        processFrame(batch)
        offset = end
    }
}

The returned sub-span inherits the lifetime of the original Span, so there is no risk of the sub-span outliving the backing memory. The name extracting was chosen deliberately over subscript(Range) to make it clear that the result has the same type (Span<T>) rather than a distinct SubSequence type.

Working with Raw Bytes via RawSpan

When you need to work with untyped bytes — for example, parsing a binary file format or implementing a network protocol — RawSpan provides a type-erased view of contiguous bytes:

func parseMovieHeader(_ rawBytes: RawSpan) -> String {
    // Read 4 bytes as a magic number
    let magic = rawBytes.unsafeLoad(as: UInt32.self)
    guard magic == 0x504958_52 else { // "PIXR" in hex
        return "Unknown format"
    }
    return "Valid Pixar Render File"
}

var headerData: [UInt8] = [0x52, 0x58, 0x49, 0x50]
headerData.withSpan { span in
    let raw = RawSpan(span)
    let result = parseMovieHeader(raw)
    print(result)
}

Apple Docs: Span — Swift Standard Library

C Interop with Span

One of the most practical use cases for Span is bridging with C APIs. Many C functions expect a pointer and a count. Span provides safe access to both:

func callCRenderingEngine(_ frameSpan: Span<UInt8>) {
    // Use withUnsafeBufferPointer only at the C boundary,
    // keeping the rest of your Swift code fully safe.
    frameSpan.withUnsafeBufferPointer { buffer in
        guard let baseAddress = buffer.baseAddress else { return }
        c_render_frame(baseAddress, Int32(buffer.count))
    }
}

This pattern confines Unsafe code to the narrowest possible scope — the single line where you cross the C boundary. Everything above and below stays in safe Swift territory.

Performance Considerations

The performance advantage of Span comes from three properties:

  1. No reference counting. Span is a non-copyable value type. Passing it to a function does not increment or decrement a reference count. In tight loops processing millions of elements, this alone can be significant.

  2. No copy-on-write checks. Array must check its buffer’s uniqueness before mutation. Span does not own memory — it borrows it — so there is no uniqueness check to perform.

  3. Bounds checking without undefined behavior. Unlike UnsafeBufferPointer, where an out-of-bounds access is UB (and may silently “work” in debug but crash in release), Span traps deterministically. This means you get the same performance characteristics as unsafe code with debug-build-level safety guarantees.

Here is a comparison of the overhead profile for a tight loop over 1 million elements:

ApproachRetain/ReleaseBounds CheckUB on Overflow
Array (passed by value)Yes (CoW)YesNo
Array.withUnsafeBufferPointerNoNoYes
SpanNoYesNo

Span sits in the sweet spot: the same absence of retain/release overhead as UnsafeBufferPointer, but with bounds checking that traps on violation rather than producing undefined behavior.

Tip: When profiling with Instruments, look for swift_retain and swift_release calls in hot paths. Replacing Array parameters with Span eliminates these entirely from the call site.

When the Compiler Can Elide Bounds Checks

The Swift optimizer can remove bounds checks in Span when it can prove the index is in range — for example, when you iterate using for i in span.indices. In these cases, the generated code is identical to raw pointer arithmetic. The optimizer has an easier time reasoning about Span than Array because there is no aliasing concern: the ~Escapable constraint guarantees the span is the only live view of that memory region at that scope.

When to Use (and When Not To)

ScenarioRecommendation
Hot loop processing contiguous dataUse Span — zero overhead at scale
Read-only buffer views across callsUse Span to skip retain/release
C/C++ interop (pointer + count)Span in Swift, unsafe at FFI only
In-place mutation of buffer contentMutableSpan with inout semantics
Storing buffer for later (escaping)Use ArraySpan cannot escape
map, filter, reduce chainsUse Array or Slice instead
Small collections, no perf pressureUse Array — savings are negligible

The key mental model: Span is a view, not a container. It does not own memory. If you need ownership semantics — storing data, passing it asynchronously, returning it from a function — reach for Array or InlineArray. If you need a fast, safe, temporary window into someone else’s memory, reach for Span.

Tip: A good heuristic is to accept Span in leaf functions that process data and accept Array (or generic Collection) in API boundaries that need flexibility. Over time, as the ecosystem adopts Span, you will find more Apple frameworks accepting it directly.

Summary

  • Span provides bounds-checked, zero-overhead read access to contiguous memory. It is as safe as Array and as fast as UnsafeBufferPointer.
  • MutableSpan adds write access with exclusive (inout) borrowing semantics, preventing aliased mutations.
  • Both types are ~Escapable — the compiler enforces that they cannot outlive the memory they reference, eliminating dangling-pointer bugs at compile time.
  • Lifetime dependencies (@lifetime) let you write functions that return Span values while preserving safety guarantees.
  • Use Span in hot paths, C interop boundaries, and data-processing pipelines. Use Array when you need ownership, escapability, or Collection conformance.

For the complete picture of stack-allocated storage that pairs naturally with Span, see InlineArray and Stack-Allocated Collections. And if ~Escapable piqued your interest in Swift’s ownership model, Noncopyable Types dives into ~Copyable, consuming, and borrowing semantics.