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
- Introducing Span
- MutableSpan: Write Access Without Copies
- Lifetime Dependencies and ~Escapable
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
spanproperty was introduced alongside the lifetime dependency system in Swift 6.2. On older compilers, use the closure-basedwithSpanAPI 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:
| Type | Access | Exclusivity | Typical API Pattern |
|---|---|---|---|
Span | Read-only | Shared borrowing | collection.span |
MutableSpan | Read-write | Exclusive (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:
@lifetimeannotations are not optional sugar. Without them, the compiler has no way to verify that a returnedSpanis safe. If you write functions that produceSpanvalues 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:
-
No reference counting.
Spanis 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. -
No copy-on-write checks.
Arraymust check its buffer’s uniqueness before mutation.Spandoes not own memory — it borrows it — so there is no uniqueness check to perform. -
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),Spantraps 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:
| Approach | Retain/Release | Bounds Check | UB on Overflow |
|---|---|---|---|
Array (passed by value) | Yes (CoW) | Yes | No |
Array.withUnsafeBufferPointer | No | No | Yes |
Span | No | Yes | No |
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_retainandswift_releasecalls in hot paths. ReplacingArrayparameters withSpaneliminates 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)
| Scenario | Recommendation |
|---|---|
| Hot loop processing contiguous data | Use Span — zero overhead at scale |
| Read-only buffer views across calls | Use Span to skip retain/release |
| C/C++ interop (pointer + count) | Span in Swift, unsafe at FFI only |
| In-place mutation of buffer content | MutableSpan with inout semantics |
| Storing buffer for later (escaping) | Use Array — Span cannot escape |
map, filter, reduce chains | Use Array or Slice instead |
| Small collections, no perf pressure | Use 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
Spanin leaf functions that process data and acceptArray(or genericCollection) in API boundaries that need flexibility. Over time, as the ecosystem adoptsSpan, you will find more Apple frameworks accepting it directly.
Summary
Spanprovides bounds-checked, zero-overhead read access to contiguous memory. It is as safe asArrayand as fast asUnsafeBufferPointer.MutableSpanadds 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 returnSpanvalues while preserving safety guarantees. - Use
Spanin hot paths, C interop boundaries, and data-processing pipelines. UseArraywhen you need ownership, escapability, orCollectionconformance.
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.