C++ std::span | Contiguous Memory View (C++20)

Key takeaways

std::span for arrays and vectors: non-owning view, subspan, bounds, const correctness, lifetime pitfalls, and C API interop.

What is span?

std::span is a lightweight view of a contiguous memory region introduced in C++20. It provides a safe, unified interface by providing a size and pointer to an array, vector, or contiguous memory.

#include <span>
void process(std::span<int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
}
int arr[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6};
process(arr);  // C array
process(vec);  // vector

Before C++20 a function that wanted “some ints in a row” had three unsatisfying options: take const std::vector<int>& (which forces callers holding a C array or std::array to copy into a vector), take a pointer and a length (which the compiler cannot check belong together), or become a template over any container (which moves the code into a header and multiplies instantiations). std::span is the fourth option: a single concrete, non-template parameter type that any contiguous container converts into for free.

Why do you need it?:

  • Unified Interface: Treats arrays, vectors, and C arrays as one type.
  • Safety: The size travels with the pointer, so you can check bounds — but note that operator[] itself does not check (see below)
  • Performance: No copy, just reference (pointer + size)
  • Conciseness: No need to pass pointer and size separately
// ❌ Traditional method: pointer + size (inconvenient, possible error)
void process(int* data, size_t size) {
    for (size_t i = 0; i < size; ++i) {
        std::cout << data[i] << " ";
    }
}
int arr[] = {1, 2, 3};
process(arr, 3);  // Pass the size manually
// ✅ span: integrated and secure
void process(std::span<int> data) {
    for (int x : data) {  // range based for available
        std::cout << x << " ";
    }
}
process(arr);  // Size automatic inference

Characteristics of span:

  • Non-owning: Does not own the memory, only references it
  • Lightweight: Stores only pointer and size (usually 16 bytes)
  • Copyable: Copying costs are very low
  • View: Original data can be modified (const span is read-only)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::span<int> sp{vec};
sp[0] = 10;  // Edit original vec
std::cout << vec[0];  // 10 (edited)

Structure of span:

// Conceptual Implementation
template<typename T>
class span {
    T* data_;
    size_t size_;
    
public:
    span(T* data, size_t size) : data_(data), size_(size) {}
    
    size_t size() const { return size_; }
    T* data() const { return data_; }
    T& operator[](size_t index) const { return data_[index]; }
    
    // iterator
    T* begin() const { return data_; }
    T* end() const { return data_ + size_; }
};

The real std::span<T, Extent> has a second template parameter. With the default std::dynamic_extent, it stores a pointer and a size — 16 bytes on a typical 64-bit platform. With a fixed extent such as std::span<int, 4>, the size is part of the type, so the object holds only the pointer (8 bytes) and size() is a compile-time constant. Both are trivially copyable, which is why the idiomatic way to pass a span is by value: void f(std::span<const int>), not const std::span<const int>&. Passing by reference would add an indirection for no benefit.

Note also that all of span’s accessors are const member functions returning non-const T&. That is the “shallow const” design: a span is like a pointer, and constness of the pointer says nothing about the pointee. Constness of the elements is expressed in T itself, which is why the std::span<const T> spelling matters so much later in this article.

Constructing spans from arrays and vectors

#include <span>
#include <vector>
std::vector<int> v = {1, 2, 3, 4, 5};
// full span
std::span<int> sp1{v};
// partial span
std::span<int> sp2{v.data() + 1, 3};  // {2, 3, 4}
// size
std::cout << sp1.size() << std::endl;  // 5

Parameters, subranges, bounds checks, and 2D data

span as a function parameter

#include <span>
#include <vector>
#include <array>
// unified interface
void printData(std::span<const int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
}
int main() {
    int arr[] = {1, 2, 3};
    std::vector<int> vec = {4, 5, 6};
    std::array<int, 3> stdArr = {7, 8, 9};
    
    printData(arr);     // 1 2 3
    printData(vec);     // 4 5 6
    printData(stdArr);  // 7 8 9
}

All three calls compile because span has converting constructors from C arrays, std::array, and any contiguous range (C++20 std::ranges::contiguous_range with a compatible element type). The parameter is span<const int> rather than span<int>, which matters for more than documentation: a const std::vector<int>& can only convert to span<const int>, so a function taking span<int> cannot be called with a const container at all. Types that are not contiguous — std::list, std::deque, std::map — are rejected at compile time, which is the point: the span promises data()[i] works for every i < size().

A common annoyance appears when the function is a template. template<class T> void f(std::span<T>) cannot be called as f(vec), because template argument deduction does not consider the implicit conversion from std::vector<int> to std::span<int>; the compiler reports something like no matching function for call to 'f(std::vector<int>&)' with the note 'std::vector<int>' is not derived from 'std::span<T>'. You either write f(std::span{vec}) (as the sliding-window pattern below does) or make the template accept any range. For non-template functions, conversions just work.

Subranges with first, last, and subspan

#include <span>
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// first 5
std::span<int> first5{v.data(), 5};
// last 5
std::span<int> last5{v.data() + 5, 5};
// subspan
std::span<int> sp{v};
auto middle = sp.subspan(3, 4);  // {4, 5, 6, 7}

Prefer first(), last(), and subspan() over constructing spans from v.data() + offset by hand. The member functions express intent and keep the arithmetic in one place, and subspan(offset) without a count means “to the end”, which removes a whole class of off-by-one errors. They do not validate their arguments, though: sp.subspan(8, 5) on a ten-element span is undefined behavior, not an exception. Standard library debug modes (-D_GLIBCXX_ASSERTIONS for libstdc++, _LIBCPP_HARDENING_MODE for libc++, the MSVC debug runtime) turn these preconditions into assertions, and enabling one of them in test builds catches most slicing mistakes.

Checking bounds by hand

#include <span>
void safeAccess(std::span<int> data, size_t index) {
    // boundary check
    if (index < data.size()) {
        std::cout << data[index] << std::endl;
    } else {
std::cout << "out of range" << std::endl;
    }
}
int main() {
    std::vector<int> v = {1, 2, 3};
    safeAccess(v, 1);   // 2
    safeAccess(v, 10);  // Out of range
}

This check is written by hand on purpose. Unlike std::vector, C++20 std::span has no at() member — it was left out so that span stayed a zero-overhead replacement for pointer-plus-length. C++26 adds span::at(), which throws std::out_of_range; until your toolchain supports it, compare against size() yourself or rely on a hardened standard library in debug builds.

Viewing flat storage as 2D

#include <span>
void processMatrix(std::span<int> data, size_t rows, size_t cols) {
    for (size_t i = 0; i < rows; ++i) {
        for (size_t j = 0; j < cols; ++j) {
            std::cout << data[i * cols + j] << " ";
        }
        std::cout << std::endl;
    }
}
int main() {
    std::vector<int> matrix = {
        1, 2, 3,
        4, 5, 6,
        7, 8, 9
    };
    
    processMatrix(matrix, 3, 3);
}

A span is strictly one-dimensional, so the row/column arithmetic stays with the caller, and nothing stops someone passing rows * cols larger than data.size(). At minimum, assert rows * cols <= data.size() at the top of such a function. C++23 adds std::mdspan, a multidimensional non-owning view with configurable layout (row-major, column-major, strided), which is the proper tool for matrices once it is available.

Size, element access, and subviews

std::span<int> sp{v};
// size
auto size = sp.size();
auto bytes = sp.size_bytes();
// access
int first = sp.front();
int last = sp.back();
int* ptr = sp.data();
// partial range
auto sub1 = sp.first(3);      // first 3
auto sub2 = sp.last(3);       // last 3
auto sub3 = sp.subspan(2, 3); // [2] to 3

Dangling spans, const confusion, fixed extents, and raw pointers

Dangling spans after reallocation

This is the pitfall that matters most in practice, because std::span gives you zero help catching it. A span is nothing but a pointer and a size — it has no idea whether the memory it points at is still alive, and the compiler doesn’t check either. Returning a span built from a local std::vector compiles cleanly, and the bug only shows up the first time something actually reads through the dangling span, often much later and in a way that looks unrelated to the function that returned it. The rule that avoids this entirely: only build a span from something whose owner is guaranteed to outlive the span itself — a caller-owned object passed by reference, a member variable, or a global — never from a local that’s about to go out of scope.

// ❌ Dangling
std::span<int> getDanglingSpan() {
    std::vector<int> v = {1, 2, 3};
    return std::span{v};
    // v extinction
}
// ✅ Reference disambiguation
std::span<int> getSpan(std::vector<int>& v) {
    return std::span{v};
}

The dangling case that catches experienced developers is subtler than returning a span to a local: it is reallocation. A span taken from a std::vector points at the vector’s current buffer, so any push_back, insert, resize, or reserve that grows the vector may move the elements and leave the span pointing at freed memory — exactly the same rule as iterator invalidation. I have seen this in code that stored a span into a member “for later” and then kept appending to the underlying vector; it worked in tests with small inputs (no reallocation) and read garbage under real load. Sanitizers are the practical safety net here: AddressSanitizer reports these reads as heap-use-after-free, which points straight at the stale view. The other easy mistake is building a span from a temporary, e.g. std::span<const int> s = makeVector(); — the vector dies at the end of the full expression, and compilers only sometimes warn about it.

const span<T> vs span<const T>

The distinction between const std::span<int> and std::span<const int> (spelled out further down in Q6) trips people up because English reads left-to-right but C++ const placement doesn’t map onto that intuition directly. const immediately before std::span<...> makes the span object const — you can’t repoint it at different memory, but you can still write through it to the underlying data. const inside the angle brackets makes the pointee const — you can repoint the span freely, but every element it exposes is read-only. When a function should promise “I won’t modify your data” (the far more common intent), std::span<const T> is the one you want; a const std::span<T> parameter promises something callers rarely care about and still lets the function mutate every element.

std::vector<int> v = {1, 2, 3};
// read only
std::span<const int> sp{v};
// ❌ No editing possible
// sp[0] = 10;  // error
// ✅ Modifiable
std::span<int> sp2{v};
sp2[0] = 10;

Fixed vs dynamic extent

// fixed size span
std::span<int, 3> fixedSpan{arr};
// dynamic size span
std::span<int> dynamicSpan{vec};
// ❌ Size mismatch
// std::span<int, 5> sp{arr};  // error if arr size is 3

A fixed-extent span is useful when the size is part of the contract — a 16-byte key, a 4×4 matrix, an RGBA pixel — because it moves the check to compile time and lets the optimizer unroll loops. Conversions are asymmetric: a span<int, 3> converts implicitly to span<int>, but going the other way requires an explicit construction, and constructing a fixed-extent span from a pointer and a runtime size that does not match is undefined behavior rather than an error. For interfaces that are called with many sizes, stick to the dynamic extent.

Wrapping pointer-and-length APIs

int* ptr = getData();
size_t size = getSize();
// ✅ Wrapping with spans
std::span<int> sp{ptr, size};
// safe access
for (int x : sp) {
    std::cout << x << " ";
}

This is the main place span earns its keep in legacy code: wrap the pointer and length once, at the boundary where they come from a C API, and pass only the span from then on. The trust has not disappeared — if getSize() lies, the span lies too — but it is concentrated in a single line instead of being repeated at every function that receives the pair. Going back to C is just as simple: legacy(sp.data(), sp.size()). For byte-level I/O, std::as_bytes(sp) and std::as_writable_bytes(sp) produce a span<const std::byte> / span<std::byte> over the same memory, which is a clean way to express “raw buffer” without casting to char*.

span vs pointer

// ❌ Pointer (no size information)
void process(int* data, size_t size) {
    for (size_t i = 0; i < size; ++i) {
        std::cout << data[i] << " ";
    }
}
// ✅ span (including size)
void process(std::span<int> data) {
    for (int x : data) {
        std::cout << x << " ";
    }
}

Read-only views, sliding windows, and buffer wrappers

A read-only view parameter

class DataProcessor {
public:
    // const span: read-only
    double calculateAverage(std::span<const double> data) const {
        if (data.empty()) return 0.0;
        
        double sum = 0.0;
        for (double value : data) {
            sum += value;
        }
        return sum / data.size();
    }
};
// use
std::vector<double> values = {1.0, 2.0, 3.0, 4.0, 5.0};
DataProcessor processor;
double avg = processor.calculateAverage(values);

A sliding window

template<typename T>
void processWindows(std::span<T> data, size_t windowSize) {
    if (data.size() < windowSize) return;
    
    for (size_t i = 0; i <= data.size() - windowSize; ++i) {
        auto window = data.subspan(i, windowSize);
        
        // window processing
        std::cout << "Window [" << i << "]: ";
        for (const auto& value : window) {
            std::cout << value << " ";
        }
        std::cout << '\n';
    }
}
// use
std::vector<int> data = {1, 2, 3, 4, 5, 6, 7, 8};
processWindows(std::span{data}, 3);

A buffer wrapper class

class Buffer {
    std::vector<uint8_t> data_;
    
public:
    Buffer(size_t size) : data_(size) {}
    
    // Full buffer view
    std::span<uint8_t> asSpan() {
        return std::span{data_};
    }
    
    // read-only view
    std::span<const uint8_t> asSpan() const {
        return std::span{data_};
    }
    
    // partial view
    std::span<uint8_t> slice(size_t offset, size_t length) {
        return std::span{data_}.subspan(offset, length);
    }
};
// use
Buffer buffer(1024);
auto view = buffer.asSpan();
view[0] = 0xFF;

Returning spans from a class like this is a deliberate API choice: callers get cheap, bounds-aware access without being able to resize or reallocate the underlying vector, and the const overload automatically hands out read-only views on const objects. The trade-off is lifetime again — a view obtained from a Buffer must not outlive it, and nothing in the type system enforces that. For values that cross thread or ownership boundaries, return an owning type instead and keep spans for short-lived, call-scoped access.

FAQ

Q1: What is span?

A: A lightweight view of a contiguous memory region in C++20. Pointers and sizes are provided together to provide a secure and unified interface.

std::vector<int> vec = {1, 2, 3, 4, 5};
std::span<int> sp{vec};  // pointer + size
std::cout << sp.size() << '\n';  // 5
std::cout << sp[0] << '\n';      // 1

Q2: Does span copy data?

A: No. A span is a non-owning view, meaning it only references the original data. Copy cost is very low (only copy pointer + size).

std::vector<int> vec(1000000);  // big vector
// Create span: no copy (pointer + size only)
std::span<int> sp{vec};
// span copy: very fast (only copies 16 bytes)
std::span<int> sp2 = sp;

Q3: Where is span used?

A:

  • Function parameters: Integrate arrays, vectors, and C arrays
  • Partial range: slicing, windowing
  • Safe Pointer: Boundary check possible with size information included
// unified interface
void process(std::span<int> data) {
    // Array, vector, std::array are all possible
}
int arr[] = {1, 2, 3};
std::vector<int> vec = {4, 5, 6};
std::array<int, 3> stdArr = {7, 8, 9};
process(arr);
process(vec);
process(stdArr);

Q4: Is the span size fixed?

A: Supports both dynamic size and fixed size.

// Dynamic size (default)
std::span<int> dynamicSpan{vec};
// Fixed size (compile time)
std::span<int, 3> fixedSpan{arr};
// Fixed size is verified at compile time
// std::span<int, 5> wrongSpan{arr};  // error: size mismatch

Q5: How do you manage the lifespan of a span?

A: span is a non-owning view, so you need to be careful about the lifetime of the original data.

// ❌ Dangling span
std::span<int> getDanglingSpan() {
    std::vector<int> vec = {1, 2, 3};
    return std::span{vec};  // vec disappears!
}
// ✅ Safe to use
std::span<int> getSpan(std::vector<int>& vec) {
    return std::span{vec};  // vec is owned by the caller
}

Q6: What is the difference between const span and span?

A:

  • const std::span: span itself is const (cannot point to other memory)
  • std::span: The data pointed to is const (data cannot be modified)
std::vector<int> vec = {1, 2, 3};
// span<const int>: data read only
std::span<const int> sp1{vec};
// sp1[0] = 10;  // Error: Data cannot be modified
sp1 = std::span<const int>{};  // OK: span itself can be modified
// const span<int>: span itself is const
const std::span<int> sp2{vec};
sp2[0] = 10;  // OK: Data can be modified
// sp2 = std::span<int>{};  // Error: span itself cannot be modified

Q7: Can span be converted to a pointer?

A: It is possible. You can get a pointer with the data() method.

std::span<int> sp{vec};
// Get pointers
int* ptr = sp.data();
// Legacy API calls
legacyFunction(ptr, sp.size());

Q8: What are span learning resources?

A:

  • “C++20 The Complete Guide” by Nicolai Josuttis
  • C++ Core Guidelines (rules I.13 and F.24 recommend span over pointer-plus-count)
  • cppreference.com - std::span

Related posts: string vs string_view, dangling references, vector.

One-Line Summary: std::span is a lightweight, non-owning view of a contiguous region of memory: pass it by value, prefer span<const T> for inputs, and never let it outlive or survive a reallocation of the storage it points at.