Parse text without allocating with Span<T>
Splitting a line with string.Split creates a new string for every field. For large files, ReadOnlySpan<char> lets you parse the same data with almost no allocations.
Integrations often mean parsing files: a nightly CSV export, a fixed-width bank file, a log. The obvious code works well:
var parts = line.Split(',');
var sku = parts[0];
var quantity = int.Parse(parts[1]);
var price = decimal.Parse(parts[2], CultureInfo.InvariantCulture);
For each line, Split allocates an array plus one new string per field. Across a file with five million lines, that's tens of millions of short-lived objects for the garbage collector to clean up, most of which you only needed for a moment to parse a number.
ReadOnlySpan<char>: a view, not a copy
A ReadOnlySpan<char> is a window onto existing memory. Slicing it doesn't copy anything:
ReadOnlySpan<char> line = "ABC-1,3,19.99";
int first = line.IndexOf(',');
ReadOnlySpan<char> sku = line[..first]; // "ABC-1", no new string
ReadOnlySpan<char> rest = line[(first + 1)..];
Most parsing methods accept spans directly:
int quantity = int.Parse(rest[..rest.IndexOf(',')]);
Splitting into ranges
Since .NET 8, MemoryExtensions.Split writes the positions of each field into a Span<Range> you provide, instead of creating strings:
static OrderLine ParseLine(ReadOnlySpan<char> line)
{
Span<Range> fields = stackalloc Range[4];
int count = line.Split(fields, ',');
if (count != 3)
throw new FormatException($"Expected 3 fields, found {count}.");
return new OrderLine(
Sku: line[fields[0]].ToString(), // allocate only for the value you keep
Quantity: int.Parse(line[fields[1]]),
Price: decimal.Parse(line[fields[2]], CultureInfo.InvariantCulture));
}
The only allocation left is the SKU string, because the result needs to keep it. The numbers are parsed straight from the original text. (The range span has one extra slot so a line with too many fields is detected rather than silently merged into the last field.)
Where spans can't go
Span<T> is a ref struct: it can only live on the stack. You can't store one in a field of a class, capture it in a lambda, or keep it across an await. Parse synchronously, line by line, inside a non-async method, and keep your async code at the level of reading lines from the stream.
Is it worth it?
For a config file or an API response, no: readability wins, and string.Split is fine. Spans pay off when you parse a lot of text repeatedly, such as large import files, high-volume message processing or hot request paths. Measure first with BenchmarkDotNet or a memory profiler, and optimize where the allocations actually are.
Real CSV is harder
Simple splitting breaks on quoted fields containing commas, escaped quotes and embedded line breaks. For real-world CSV, use a proper parser such as CsvHelper or Sep, which are themselves built on these techniques.
Takeaway
When parsing large volumes of text, slice ReadOnlySpan<char> instead of creating substrings, parse numbers directly from spans, and only allocate strings for values you keep. Keep it for measured hot paths, and use a real CSV library for real CSV.