Introduction

When I started working on a high‑throughput log‑processing service, I quickly realized that the naive string.Split and string.Substring approach was killing performance. Each call created a new array of string objects, leading to unnecessary garbage collection pressure. A colleague reminded me of two C# features that have been in the language for a while but are often overlooked: Span<char> and the in parameter. Combining them lets you slice data without copying, and the result has become a staple in my daily toolkit.

Why Span and 'in' Matter

Span<T> is a ref‑like, stack‑allocated view over a contiguous block of memory. It can point to either managed arrays, string data, or native buffers without actually copying the underlying bytes. Because it implements ReadOnlySpan<T> and Span<T> interfaces, you can use the same APIs you know from string

The in keyword, introduced in C# 7.2, allows you to pass a parameter by reference even when the argument is not a ref‑local. This is crucial for in‑parameter methods that accept ReadOnlySpan<T> or Span<T> and can avoid copying large structs or arrays.

Real‑World Scenario

Imagine you need to parse incoming log lines that follow a simple CSV format:

  • Timestamp – ISO‑8601 string
  • Level – INFO, WARN, ERROR
  • Message – free‑form text

Each line arrives as a string from a network stream. Using string.Split would allocate three new string objects per line. By reading the raw buffer into a Span and splitting in‑place, we keep the memory footprint low and improve throughput dramatically.

Using Span and the in parameter is not just a micro‑optimization; it changes the way you think about text processing in high‑frequency scenarios.

Implementation Example

Below is a compact, production‑ready utility that splits a log line into its three components without allocating extra strings. The method accepts the raw line as a ReadOnlySpan<char> and returns a (DateTime, LogLevel, ReadOnlySpan<char>) tuple.

using System;
using System.Buffers;
using System.Globalization;

public enum LogLevel { Info, Warning, Error }

public static class LogParser
{
    // Helper to trim whitespace from a span
    private static ReadOnlySpan<char> Trim(this ReadOnlySpan<char> span)
    {
        var start = span.Length;
        var end = span.Length;
        while (start > 0 && char.IsWhiteSpace(span[start - 1])) start--;
        while (end > start && char.IsWhiteSpace(span[end - 1])) end--;
        return span[start..end];
    }

    // Main parsing method – note the 'in' parameter
    public static (DateTime Timestamp, LogLevel Level, ReadOnlySpan<char> Message) ParseLine(in ReadOnlySpan<char> line)
    {
        // Split on the first comma only
        var comma = line.IndexOf(',');
        if (comma < 0) throw new FormatException("Invalid log format");

        var timestampSpan = line[..comma].Trim();
        var remainder = line[(comma + 1)..];

        // Find second comma
        var secondComma = remainder.IndexOf(',');
        if (secondComma < 0) throw new FormatException("Invalid log format");

        var levelSpan = remainder[..secondComma].Trim();
        var messageSpan = remainder[(secondComma + 1)..].Trim();

        // Parse timestamp – DateTime.ParseExact works with spans
        if (!DateTime.TryParseExact(timestampSpan, "yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture, DateTimeStyles.None, out var timestamp))
            throw new FormatException("Invalid timestamp");

        // Map level string to enum
        var levelString = levelSpan.ToString(); // small allocation only for enum lookup
        var level = levelString switch
        {
            "INFO" => LogLevel.Info,
            "WARN" or "WARNING" => LogLevel.Warning,
            "ERROR" => LogLevel.Error,
            _ => throw new FormatException("Unknown log level"),
        };

        return (timestamp, level, messageSpan);
    }
}

The key observations are:

  1. We accept the line as an in ReadOnlySpan<char> – no copy is made.
  2. Slicing with line[..comma] creates new Span objects that reference the original buffer.
  3. The Trim extension works directly on Span and returns another span, again zero‑allocation.
  4. Only the level string is turned into a string because Enum.TryParse does not yet support ReadOnlySpan<char> in all .NET versions. In .NET 6+ you could avoid that allocation by using Enum.TryParse(levelSpan, true, out var level).

Best Practices and Gotchas

When you start using Span and in, keep a few rules in mind:

  • Stay in the same logical scope. If you need to pass a Span to a helper that expects a string, be aware of the implicit conversion that allocates.
  • Prefer ReadOnlySpan for input parameters. It signals that you will not modify the data and allows the caller to pass both string and array without copying.
  • Benchmark before you optimize. The performance gain is most noticeable when processing many lines; for a handful of operations the extra syntax may not be worth the cognitive load.
  • Use MemoryMarshal when you need to interoperate with native APIs. It lets you get a pointer to the underlying buffer safely.

One common pitfall is assuming that slicing a Span copies data. In reality, the slice is a new struct that points to the same memory, so any mutation through one slice will affect the other. This is fine for read‑only parsing but must be considered if you later introduce mutable spans.

Conclusion

The combination of Span<char> and the in parameter gives you a powerful, low‑allocation way to work with text in C#. By treating strings as views over existing buffers, you reduce garbage collection pressure and improve throughput in scenarios like log parsing, CSV processing, or any high‑frequency text manipulation. The example above demonstrates a clean, production‑ready implementation that you can drop into a microservice or a high‑performance library. Give it a try on your next text‑heavy project, and you’ll likely notice the difference in both performance counters and developer satisfaction.