C# & .NET (C# 14, .NET 10 LTS) Module 17 – Memory, Span and performance
Span<T>, ReadOnlySpan<T> and Memory<T>
Slice arrays and strings without copying with Span<T> and ReadOnlySpan<T>, keep a buffer across await with Memory<T>, and parse text without allocating.
What you will learn
- Slice arrays and strings without copying them, and write through a span
- Choose between Span<T> and Memory<T> for code that stores a buffer or awaits
- Parse text records without allocating, and reject malformed records
- Read and fix the compiler errors that keep spans safe, and the overload changes of C# 14
Before you start
What you’ll learn · Before you start
What you will learn
- Slice arrays and strings without copying them, and write through a span
- Choose between Span<T> and Memory<T> for code that stores a buffer or awaits
- Parse text records without allocating, and reject malformed records
- Read and fix the compiler errors that keep spans safe, and the overload changes of C# 14
Before you start
On this page
Reading the age out of 10,000 records such as “Asha,18,Pune” with string.Split allocated 150.1 bytes a record: an
array and three new strings for every record, made only to be thrown away. Reading the same records through a span: A Span<T> or ReadOnlySpan<T>: a view of a run of values that sit next to each other in memory, such as part of an array, which reads and writes them where they are. array.AsSpan(1, 3) refers to three elements of array and copies nothing. More in “Arrays, indices and ranges”
allocated nothing at all.
A span is a view of memory that belongs to something else: part of an array, a string or the stack. This lesson shows the two span types, Span<T> and ReadOnlySpan<T>, which read and write such views in place, and Memory<T>: A struct that views a run of values in an array, a string or a pooled buffer, like a span, but can be stored in fields and kept across await. Its Span property gives a span for synchronous work., the view that code can keep in a field and use across await. You will slice without copying, pick the right type for code that waits, and parse text without allocating.
A span views memory it does not own, and copies nothing
views.cs slices an array, writes through the slice, then adds up the ages of 10,000 records in two ways:
// A span is a view: slicing copies nothing, writing through a span changes the memory it views, and a string can be
// read through a span without making new strings.
int[] marks = [41, 67, 88, 52, 73, 90, 35, 64];
Span<int> middle = marks.AsSpan(2, 4); // positions 2 to 5 of marks, not a copy
middle[0] = 99; // writes marks[2]
Console.WriteLine($"marks: {string.Join(", ", marks)}");
Console.WriteLine($"middle: {middle.Length} values from position 2, first {middle[0]}, last {middle[^1]}");
string[] records = MakeRecords(10_000);
Console.WriteLine($"with Split: {BytesPerRecord(SumAgesWithSplit, records),5:F1} bytes a record, total {SumAgesWithSplit(records)}");
Console.WriteLine($"with spans: {BytesPerRecord(SumAgesWithSpans, records),5:F1} bytes a record, total {SumAgesWithSpans(records)}");
Span<char> digits = stackalloc char[11]; // a span over the stack: no heap at all
bool written = 41_250.TryFormat(digits, out int length);
Console.WriteLine($"stackalloc: {written}, {length} characters: {digits[..length]}");
// Adds up the ages of the records, reading each one with string.Split.
static long SumAgesWithSplit(string[] records)
{
long total = 0;
foreach (string record in records)
{
string[] fields = record.Split(','); // a new array and three new strings
total += int.Parse(fields[1]);
}
return total;
}
// The same, reading each record through a ReadOnlySpan<char>.
static long SumAgesWithSpans(string[] records)
{
long total = 0;
foreach (string record in records)
{
ReadOnlySpan<char> line = record;
ReadOnlySpan<char> rest = line[(line.IndexOf(',') + 1)..]; // after the name
total += int.Parse(rest[..rest.IndexOf(',')]); // up to the city
}
return total;
}
static double BytesPerRecord(Func<string[], long> sumAges, string[] records)
{
sumAges(records); // warm up first, so that only the work itself is counted
long before = GC.GetAllocatedBytesForCurrentThread();
sumAges(records);
return (GC.GetAllocatedBytesForCurrentThread() - before) / (double)records.Length;
}
static string[] MakeRecords(int count)
{
string[] names = ["Asha", "Ravi", "Meera", "Kabir", "Ishaan", "Zoya"];
string[] cities = ["Pune", "Kochi", "Indore", "Mysuru", "Guwahati"];
var records = new string[count];
for (int i = 0; i < count; i++) records[i] = $"{names[i % names.Length]},{18 + i % 50},{cities[i % cities.Length]}";
return records;
} Output
marks: 41, 67, 99, 52, 73, 90, 35, 64 middle: 4 values from position 2, first 99, last 90 with Split: 150.1 bytes a record, total 425000 with spans: 0.0 bytes a record, total 425000 stackalloc: True, 5 characters: 41250
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run views.cs
marks.AsSpan(2, 4) made no new array. The span holds a reference to marks[2] and a length of 4, so writing
middle[0] wrote marks[2], which now holds 99. That is all a Span<T> is: a view of values that sit next to
each other in memory, such as an array, part of one or memory from stackalloc, read and written in place. A
ReadOnlySpan<T> is the same view without writing, and it is what a string gives you, because strings never change.
Step through the program:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A slice of the array. marks.AsSpan(2, 4) copies nothing. The span stores where position 2 of marks is and that it covers 4 values; the 8 marks stay in their array.
- Writing through it. middle[0] = 99 writes into the memory the span views: position 0 of the span is position 2 of marks, so the array now holds 99 there.
- A view of a string. ReadOnlySpan<char> line = record makes a read-only view of the first record, "Asha,18,Pune", 12 characters long. It cannot write, because strings never change, and it allocates nothing.
- A slice of the view. line[(line.IndexOf(',') + 1)..] is another view of the same characters, starting after the first comma: "18,Pune", 7 characters. Still nothing is copied.
- Parsing without a string. int.Parse has an overload that reads a ReadOnlySpan<char>, so the age is read straight from the record: the slice "18" goes in, the number 18 comes out, and no string is made.
- A view of the stack. stackalloc char[11] gives a span over 11 characters on the stack, where TryFormat writes 41250. That memory is gone when the method returns, and the compiler makes sure no span outlives it.
In the parse loop, Split made an array and three strings for every record, 150.1 bytes, while the span version found
the commas with IndexOf, sliced, and gave the slice to int.Parse, which has an overload for spans: 0.0 bytes, for
the same total.
Turning every slice back into a string
rest[..comma].ToString() copies the slice into a new string, and a parser that does it for every field allocates
as much as Split did. Keep fields as spans and give them to the span overloads of int.Parse, double.Parse and
SequenceEqual; make a string only for a value you keep.
Checkpoint
Check: A span as a view of memory
Memory<T> is the view you can keep across await
A span cannot leave the stack. Span<T> is a ref struct: A struct declared with ref struct, such as Span<T>. Its instances live only on the stack: it cannot be boxed, be a field of a class, be captured by a lambda or be kept across an await. More in “ref structs, ref fields and scoped”, so a class cannot have a
span field, and an async method cannot keep a span across an await, because its locals may move to the heap while it
waits. Memory<T> and ReadOnlyMemory<T> view the same arrays and strings, or a buffer rented from a pool,
but are ordinary structs, which can be stored anywhere; their Span property gives a span for the synchronous work in between. memory.cs keeps a
read-only memory in a field and returns one line per await:
// Memory<T> can be kept in a field and used across await; .Span gives a span for the synchronous work in between.
var reader = new LineReader("Asha,34,Pune\nRavi,41,Kochi\nMeera,29,Indore\n".AsMemory());
int count = 0;
while (await reader.NextAsync() is ReadOnlyMemory<char> line)
Console.WriteLine($"line {++count}: {line.Length} characters, \"{line}\"");
Console.WriteLine($"{reader.Remaining} characters left");
sealed class LineReader(ReadOnlyMemory<char> text)
{
private ReadOnlyMemory<char> _rest = text; // a field of a class: allowed for Memory<T>, refused for Span<T>
public int Remaining => _rest.Length;
public async Task<ReadOnlyMemory<char>?> NextAsync()
{
await Task.Yield(); // stands in for waiting on the network
if (_rest.IsEmpty) return null;
int end = _rest.Span.IndexOf('\n'); // a span for the synchronous work, between two awaits
ReadOnlyMemory<char> line = _rest[..end];
_rest = _rest[(end + 1)..];
return line;
}
} Output
line 1: 12 characters, "Asha,34,Pune" line 2: 13 characters, "Ravi,41,Kochi" line 3: 15 characters, "Meera,29,Indore" 0 characters left
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run memory.cs
Each call awaited first, then took _rest.Span to find the next newline, and sliced the memory itself for the line it
returned and the rest it kept. The three lines and the field all point into the one string the reader was given.
| Span<T>, ReadOnlySpan<T> | Memory<T>, ReadOnlyMemory<T> | |
|---|---|---|
| Kind of type | a ref struct, stack only | a struct that lives anywhere |
| A field of a class | refused | allowed |
| Across await or in a lambda | refused | allowed |
| A view of stack memory | yes | no |
| Reading an element | index it | take .Span first |
| Use it for | synchronous work | buffers kept or awaited |
Microsoft’s usage guidelines turn the table into two rules for APIs: a synchronous method takes a Span<T>,
because any caller that holds a Memory<T> can pass its .Span, and a method that only reads takes the read-only
type. For parsing, int.Parse reads a ReadOnlySpan<char> directly. MemoryExtensions.Split finds the fields of
a span without making strings. SearchValues prepares a set of characters once for fast searches. C# 14 converts
arrays to spans in more places, so more span methods can be called on arrays directly. Code that keeps a
Memory<T> must also respect how long it may use it, which the guidelines call a lease.
Checkpoint
Check: Span or Memory for code that keeps or awaits
Parsing records with spans, measured to the byte Pro: opens with a Pro, Premium or Ultimate pass
Real records have several fields, delimiters that vary and mistakes in them. This part parses them with the span APIs of .NET 8 and .NET 9, measures each way to the byte, and ends with a parser for sensor records that you finish.
In this part:
- Splitting a record into ranges, without one new string
- Searching for any of many characters with SearchValues
- A sensor record parser that allocates nothing
3 runnable examples (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
What the compiler refuses, and what C# 14 converts for you Pro: opens with a Pro, Premium or Ultimate pass
The rules that keep spans on the stack are checked when the code compiles, never at run time. This part reproduces the two errors met most often, fixes them, and shows C# 14’s new conversions with the one overload change that can fail at run time.
In this part:
- CS8175 and CS4007, reproduced and fixed
- C# 14 turns arrays into spans for you
- An overload that C# 14 picks differently
6 runnable examples (C#, Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Who may use a Memory buffer across async code, and until when Pro: opens with a Pro, Premium or Ultimate pass
A Memory<T> can be kept, which raises the question of for how long. This part states the ownership rules of Microsoft’s usage guidelines, breaks one of them on purpose and fixes it, so you can spot the bug in a code review.
In this part:
- The lease: what a method may do with a Memory<T> it was given
- The rules, and the owner types that make them explicit
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Check yourself on views and slicing and on choosing between a span and Memory<T>. The session picks the next question from your earlier answers and explains each choice as soon as you make it:
Practice that adapts to you
Practise this lesson
6 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is the difference between Span<T> and Memory<T>?
Both are views of a run of values that sit next to each other in memory, such as part of an array or a string, and
neither copies what it views. Span<T> is a ref struct: it can live only on the stack, so it cannot be a field of a
class, be captured by a lambda or be kept across an await, but it can also view stack memory from stackalloc, and it
is the fastest to use. Memory<T> is an ordinary struct that can be stored anywhere, so it suits buffers that
async code or an object keeps; to work on its values you take its Span property. A synchronous method should take a
span, and code that stores a buffer or awaits should take a Memory<T>.
-
Mid to senior
A CSV import service parses 2 million lines a minute with string.Split and int.Parse, and an allocation profile shows that most of its garbage comes from parsing. How would you rewrite the parser with spans, and what would you measure before and after?
-
Mid to senior
A developer turns a synchronous parsing method into an async one that awaits a lookup between two steps, and the build fails with CS4007 on a ReadOnlySpan<char> local. Explain the error and two ways to fix it, with what each costs.
-
Senior to staff
After a library moves to C# 14, a call that passes an array to one of its overloaded methods picks a different overload, and one caller's test now throws ArrayTypeMismatchException. Explain what changed and how you fix the call and the library.
Key takeaways
- A span is a view: slicing an array or a string with a span copies nothing, and writing through a Span<T> writes the memory it views.
- Parsing through ReadOnlySpan<char> with the span overloads of
int.Parseand the like reads text without making strings, 0 bytes a record against 150.1 withSplit. - Span<T> is a ref struct that stays on the stack; Memory<T> can be kept in fields and across await, and its
Spanproperty gives a span for the synchronous work.
Terms in this lesson
- Memory<T>
- A struct that views a run of values in an array, a string or a pooled buffer, like a span, but can be stored in fields and kept across await. Its Span property gives a span for synchronous work.
References
- Memory-related and span types (Microsoft Learn)
- Memory<T> and Span<T> usage guidelines (Microsoft Learn)
- Span<T> struct (Microsoft Learn)
- MemoryExtensions.Split method (Microsoft Learn)
- SearchValues class (Microsoft Learn)
- ref struct types (Microsoft Learn)
- What's new in C# 14 (Microsoft Learn)
- C# compiler breaking changes since C# 13 (Microsoft Learn)
- Exceptions and performance (Microsoft Learn)
- What's new in C# 13 (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress