# Removing byte[] allocations in .NET Framework using ReadOnlySpan<T>

> Source: <https://andrewlock.net/removingbyte-array-allocations-in-dotnet-framework-using-readonlyspan-t/>
> Published: 2026-04-21 10:00:00+00:00

In this post I describe a simple way to remove some `byte[]`

allocations, no matter which version of .NET you're targeting, including .NET Framework. This will likely already be familiar to you if you write performance sensitive code with modern .NET, but I recently realised that this can be applied to older runtimes as well, like .NET Framework.

This post looks at the changes to your C# code to reduce the allocations, how the compiler implements the change behind the scenes, and some of the caveats and sharp edges to watch out for.

`Span<T>`

and `ReadOnlySpan<T>`

are a performance mainstay for .NET

`Span<T>`

and `ReadOnlySpan<T>`

are a performance mainstay for .NET`ReadOnlySpan<T>`

and `Span<T>`

were introduced into .NET a long time ago now, but they have had a significant impact on the code you can (and arguably *should*) write, particularly when it comes to performance sensitive code. These provide a "window" or "view" over existing data, without creating copies of that data.

The classic example is when you're manipulating `string`

objects; instead of using `SubString()`

, and creating additional copies of segments of the string, you can use `AsSpan()`

to create `ReadOnlySpan<char>()`

segments that can be manipulated almost *as though* they are separate `string`

instances, but without all the copying.

This is probably the most common use of `Span<T>`

in application code, but fundamentally the use of `Span<T>`

to provide a view over any piece of memory means it's useful in many other situations. The fact that the backing of a `Span<T>`

can be almost anything, means you can keep the same "public" API which potentially "swapping out" the backend.

Another common example of this is if you have some parsing (or similar) code and you need a buffer to store the temporary results. Prior to `Span<T>`

, you would almost certainly have allocated a normal array on the heap for this, but with `Span<T>`

, "stack allocating" using `stackalloc`

becomes just as easy, and reduces pressure on the garbage collector:

```
Span<byte> buffer = requiredSize <= 256                  // If the required buffer size is small 
                        ? stackalloc byte[requiredSize]  // enough, then allocate on the stack.
                        : new byte[requiredSize];        // Fallback to a normal heap allocation
```

Virtually all new .NET runtime APIs are added with `Span<T>`

or `ReadOnlySpan<T>`

support, and you can even use them in old runtimes like `.NET Framework`

via [the System.Memory NuGet package](https://www.nuget.org/packages/System.Memory) (though you don't get all the same perf benefits that you do with .NET Core).

The ability to easily and safely (without needing directly falling back to `unsafe`

and pointers) work with blocks of memory regardless of where they're from has really made `Span<T>`

vital for any code that cares about performance. But this ability to provide an "arbitrary" view over memory also provides a way for the compiler to perform additional optimizations, as we'll see in the next section.

[Removing ](#removing-byte-allocations-with-readonlyspanbyte)`byte[]`

allocations with `ReadOnlySpan<byte>`

`byte[]`

allocations with `ReadOnlySpan<byte>`

The ability for the compiler to provide a view over arbitrary memory is what drives the optimization I'm going to talk about for the rest of this post.

Let's imagine you have some `byte[]`

that you need for *something*. Some kind of processing requires it. You know the data it needs to contain upfront, so you store the array in a `static readonly`

field, so that the data is only created once:

```
public static class MyStaticData
{
    private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };
}
```

This works absolutely fine, but it means when you first access that data, the runtime needs to create an instance of the array, fill it with the data, and store it in the field. After that, accessing the field is cheap, but the initial creation adds a small delay to the first use of that type.

However, starting with C# 8.0, and as long as that you only need a readonly view of the data, you can use a slightly different pattern, by exposing a `ReadOnlySpan<byte>`

*property* instead of a field:

```
public static class MyStaticData
{
    // Before
    private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };

    // After
    private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };
}
```

Now, *normally*, that's the sort of code that should be setting off performance alarm bells. It *looks* like it will be creating a *new* `byte[]`

every time you access the property😱 But that's *not* what's happening.

We'll take a detailed look at the generated IL code shortly, for now we'll just talk at a high level.

When the compiler sees the pattern above, it does the following:

- Embed the
`byte[]`

data into the final assembly's metadata - When
`ReadOnlySpanProp`

is invoked, instead of creating a`byte[]`

, create a`ReadOnlySpan<byte>`

that points directly to the data in the assembly

So the returned `ReadOnlySpan<byte>`

isn't pointing to data that exists on the heap or even on the stack; it's pointing to data that's embedded *directly* in the assembly. That means there's no allocation at all, which removes that startup overhead and means there's no pressure at all on the garbage collector 🎉

It's worth noting as well that this is a *compiler* feature, which means that as long as a `System.ReadOnlySpan<T>`

type is available, you can use it. So as long as you add the System.Memory NuGet package to your .NET Framework app, you too can benefit from this zero-allocation technique!

Also, this doesn't *just* apply to converting `static readonly byte[]`

fields to `static ReadOnlySpan<byte>`

properties; it also applies to *local* variables too. Which means things like the following, which *look* like they allocate an array, actually don't:

```
public static void TestData()
{
    // This looks like it allocates, but it doesn't
    ReadOnlySpan<byte> arr = new byte[] { 0, 1, 2 };
}
```

Another minor thing to point out is that this also works with [UTF-8 Strings Literals](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-11.0/utf8-string-literals), which are logically represented as a `byte[]`

by the type system. So this is also zero allocation:

```
public static class MyStaticData
{
    private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => "Hello world"u8;
}
```

That's all great, but when I first used the `byte[]`

approach, I was a *little* concerned. After all, it *looked* like it would be allocating and terribly inefficient, so I wanted to be sure. And what better way than checking the IL code the compiler generates.

[What's happening behind the scenes?](#what-s-happening-behind-the-scenes-)

There are multiple ways to check the generated code that the compiler generates. If you just want to check a "snippet" of code, then [sharplab.io is a quick and easy option](https://sharplab.io/#v2:EYLgtghglgdgNAExAagD4AEBMBGAsAKAPQGYACdbANnM1IFkBPAZQBcIWoBjAEXYgIDeBUiNIB6MaQDyAaWGiADgCcoAN3YBTclVJKNEBAHsYAGwalgDFhoDaAXVIAhKxoBiUDSYSkAvKRgaAO4WLvakAqSYcKTE0QAs0QCspAC+ANzyIspqmtrUltZhAKosAGYAHAAKSoYKGkos5n4ARAASniaGpIGGSl7NAK7lAHQAKoYAgkpKEAwAFACUGYT4oqTZ6tZ5pABK+ghSpswKEDAAPAUaAHy7+4dmTCcw1bW+NwHBl2ER2NFRMfFUssCCkgA=). Alternatively, there's [ILSpy](https://github.com/icsharpcode/ilspy), or the JetBrains tools like [dotPeek](https://www.jetbrains.com/decompiler/) and [Rider](https://www.jetbrains.com/rider/), and I'm sure Visual Studio has plugins for it.

To comfort myself, I first created a new .NET project using `dotnet new classlib`

, and then I tweaked it to use .NET Framework. To be clear, the techniques shown so far work on all target frameworks, but I wanted to specifically test with .NET Framework, to prove that it's not just "new" frameworks this works with. I tweaked the project file as shown below:

```
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- Change the target framework to .NET Framework👇 -->
    <TargetFramework>net48</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <!-- Use the latest C# version (C# 14 with .NET 10 SDK)👇 -->
    <LangVersion>latest</LangVersion>
  </PropertyGroup>
  
  <!-- Reference System.Memory so we can use ReadOnlySpan<T>👇 -->
  <ItemGroup>
    <PackageReference Include="System.Memory" Version="4.6.3" />
  </ItemGroup>
</Project>
```

I then created the very simple class below, compiled, and used Rider to view the generated IL:

```
public static class MyStaticData
{
    private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };
    private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => "Hello world"u8;
}
```

I've commented the IL below to describe what it's doing, but the *important* thing is that we don't see any calls to `newarr`

, `InitializeArray()`

, or `ToArray()`

, or other problematic calls. Instead, we see IL code that loads an address which points to data embedded in the PE image (i.e. the assembly), loads the *length* of the data (4 bytes), and then passes the pointer and length to the `new ReadOnlySpan<T>()`

constructor and returns it. No copying, no new arrays, just a wrapper around bytes that are already loaded into memory 🎉

```
.class public abstract sealed auto ansi beforefieldinit
  MyStaticData
    extends [mscorlib]System.Object
{

  .field private static initonly unsigned int8 One

  .method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>
    get_ReadOnlySpanProp() cil managed
  {
    .maxstack 8

    // 👇 Push the address of the static field that contains the array data as a blob onto the stack
    IL_0000: ldsflda      int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
    // 👇 Push the value '4' onto the stack
    IL_0005: ldc.i4.4
    // 👇 Create a new ReadOnlySpan<byte>
    IL_0006: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)
    IL_000b: ret // Return the span

  } // end of method MyStaticData::get_ReadOnlySpanProp

  .method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>
    get_ReadOnlySpanUtf8() cil managed
  {
    .maxstack 8

    // 👇 Push the address of the static field that contains the UTF-8 data as a blob onto the stack
    IL_0000: ldsflda      valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>'::'27518BA9683011F6B396072C05F6656D04F5FBC3787CF92490EC606E5092E326'
    // 👇 Push the value '11' onto the stack (the length of "Hello world" in UTF-8)
    IL_0005: ldc.i4.s     11 // 0x0b
    // 👇 Create a new ReadOnlySpan<byte>
    IL_0007: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)
    IL_000c: ret // Return the span

  } // end of method MyStaticData::get_ReadOnlySpanUtf8
}
```

Great, we can see that it's clearly working as we expected *and* this is .NET Framework, so it is just a compiler feature and has no runtime requirements, so we can use it everywhere.

But we need to be careful… I showed that it works for `byte[]`

, but it doesn't work for *everything*…

[Be careful, things can go wrong…](#be-careful-things-can-go-wrong)

If you've read this far, you might be thinking "great, I'll use this for all my static array data", but I'm going to stop you there. Here-be dragons. The pattern above is *only* safe to use:

- If you have a
`byte[]`

,`sbyte[]`

, or`bool[]`

. - If
*all*the values in the array are constants - If the array is immutable (i.e. you return a
`ReadOnlySpan<T>`

not a`Span<T>`

).

Breaking any of these rules may be disastrous for performance, so we'll examine each in turn.

[Only ](#only-byte-sbyte-and-bool-are-allowed)`byte`

, `sbyte`

, and `bool`

are allowed

`byte`

, `sbyte`

, and `bool`

are allowedThe compiler optimizations shown so far can *only* be applied to `byte`

-sized primitives, i.e. `byte`

, `sbyte`

, and `bool`

. That's because the constant data would be stored in a little endian format, and needs to be translated to the *runtime* endian format, e.g. if the application is run on hardware which utilizes big endian numbers.

That means, that if you do the following (using `int`

instead of `byte`

), then the code *compiles* just fine, but unfortunately it doesn't generate the "zero allocation" code that you might expect:

```
public static class MyStaticData
{
    // ⚠️ Using `int` instead of `byte` _does_ cause an array 
    // to be allocated (on .NET Framework and < .NET 7)
    private static ReadOnlySpan<int> ReadOnlySpanPropInt => new int[] { 1, 2, 3, 4 };
}
```

If we check the generated IL for a .NET Framework app with the above, we can see the problematic `newarr`

and `InitializeArray`

calls. The compiler actually does some work to avoid the *really* problematic pattern which would create an array every time, by creating the array *once*, caching it in a static field, and then using that cached data for subsequent calls, but it still has a startup cost, and does more work than the optimized `byte[]`

approach:

```
.method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<int32>
  get_ReadOnlySpanPropInt() cil managed
{
  .maxstack 8

  // 👇Try to load the cached int[] data from the static 'cache' field
  IL_0000: ldsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
  IL_0005: dup                                  // Duplicate the variable
  IL_0006: brtrue.s     IL_0020                 // If the data isn't null, we have it cached, so jump to the end
  IL_0008: pop                                  // The value was null, remove the duplicate
  IL_0009: ldc.i4.4                             // Load the length of the data (4)
  IL_000a: newarr       [mscorlib]System.Int32  // Allocate a new array on the heap
  IL_000f: dup                                  // Keep a copy of the array variable
  // 👇 Load the address of the int[] data embedded in the assembly
  IL_0010: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72
  // 👇 Initialize the new array with the int[] data
  IL_0015: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
  IL_001a: dup                                  // Duplicate the variable
  // 👇 Store the now-populated array into the static 'cache' field
  IL_001b: stsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
  // 👇 Create the `ReadOnlySpan<int>` wrapping the array
  IL_0020: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])
  IL_0025: ret

} // end of method MyStaticData::get_ReadOnlySpanPropInt
```

So the "good" news is that this isn't *much* different to just using a `static readonly int[]`

, but it's still not ideal, and definitely *isn't* the zero-allocation version that you get with `byte[]`

.

Additionally, if you're on .NET 7+, [a new API was added](https://github.com/dotnet/runtime/issues/60948) which actually *does* support this pattern. So if we change the target framework (to .NET 10 in this case), and recompile, then the IL is back to the zero allocation version, thanks to the call [to RuntimeHelpers::CreateSpan](https://github.com/dotnet/runtime/blob/b70c35ed8a2e7ae0d91de76f4f5d26c2e7d2c6cd/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.cs#L154), which handles fixing-up any endianness issues:

```
.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<int32>
    get_ReadOnlySpanPropInt() cil managed
  {
    .maxstack 8

    // 👇 Load the address of the data
    IL_0000: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16_Align=4' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B724
    // 👇 Call RuntimeHelpers::CreateSpan and return
    IL_0005: call         valuetype [System.Runtime]System.ReadOnlySpan`1<!!0/*int32*/> [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<int32>(valuetype [System.Runtime]System.RuntimeFieldHandle)
    IL_000a: ret

  } // end of method MyStaticData::get_ReadOnlySpanPropInt
```

So in summary, your mileage will vary here, and you don't really gain anything unless you're on .NET 7+. If you need to target older frameworks, then you're potentially better off just sticking to a good old `static readonly int[]`

field instead.

[All values must be constants](#all-values-must-be-constants)

The next issue is that the whole approach shown in this post *only* works if all the values in the collection are *constants*. For example, the following example which uses a `static readonly`

value inside the array *compiles* just fine:

```
public static class MyStaticData
{
    private static readonly byte One = 1;
    private static ReadOnlySpan<byte> ReadOnlySpanPropNonConstant => new byte[] { One, 2, 3, 4 };
}
```

but *even* on .NET 7+, this *won't* do the zero-allocation approach that you might be expecting. Instead, you get some really nasty "allocate a new array every time" behaviour 😱:

```
.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>
  get_ReadOnlySpanPropNonConstant() cil managed
{
  .maxstack 8

  IL_0000: ldc.i4.4                                  // Laod the length of the array
  IL_0001: newarr       [System.Runtime]System.Byte  // Create a new array
  IL_0006: dup                                       // Duplicate the variable reference
  // 👇 Get a reference to the data, and initialize the array
  IL_0007: ldtoken      field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'
  IL_000c: call         void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
  IL_0011: dup
  IL_0012: ldc.i4.0                                      // Load the index to change '0'
  IL_0013: ldsfld       unsigned int8 MyStaticData::One  // Load the static field `One`
  IL_0018: stelem.i1                                     // Set array[0] = One
  // 👇 return the ReadOnlySpan<byte> around the new array
  IL_0019: call         valuetype [System.Runtime]System.ReadOnlySpan`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])
  IL_001e: ret
} // end of method MyStaticData::get_ReadOnlySpanPropNonConstant
```

That's…bad 😬 And it does it on *every* property access. Definitely watch out for that one, on *all* target frameworks.

[Only use ](#only-use-readonlyspant-not-spant)`ReadOnlySpan<T>`

, not `Span<T>`

`ReadOnlySpan<T>`

, not `Span<T>`

You have a similar "dangerous" scenario if you use `Span<T>`

instead of `ReadOnlySpan<T>`

:

``` js
public static class MyStaticData
{
    private static Span<byte> SpanProp => new byte[] { 1, 2, 3, 4 };
}
```

In this case, because you're returning *mutable* data (`Span<T>`

instead of `ReadOnlySpan<T>`

), the compiler can't use any of its fancy tricks, because the data needs to be mutable. All it can do is create a new array, initialize it with the correct initial values, and then hand it back wrapped in a mutable `Span<T>`

:

```
.method private hidebysig static specialname valuetype [System.Runtime]System.Span`1<unsigned int8>
  get_SpanProp() cil managed
{
  .maxstack 8

  // [32 43 - 32 68]
  IL_0000: ldc.i4.4
  IL_0001: newarr       [System.Runtime]System.Byte
  IL_0006: dup
  IL_0007: ldtoken      field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
  IL_000c: call         void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
  IL_0011: call         valuetype [System.Runtime]System.Span`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.Span` 1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])
  IL_0016: ret

} // end of method MyStaticData::get_SpanProp
```

The failure path here is understandable, because there's really no way to do a safe zero-allocation approach when the data needs to be mutable. The big problem is that it's not *obvious* that it's a super-allocatey property instead of a zero-allocation version. If you accidentally fat-finger and write `Span<T>`

instead of `ReadOnlySpan<T>`

, or, you know, Claude does, then it's *really* not obvious from simply reviewing the code…

The only good news is that if you use modern features, namely collection expressions, you *might* catch the issue!

[Reducing the risk of errors with collection expressions](#reducing-the-risk-of-errors-with-collection-expressions)

So how do collection expressions help here? Well, those last two points, where one of the values isn't a constant, or where the variable is `Span<T>`

instead of `ReadOnlySpan<T>`

simply won't compile if you use the `static`

property pattern with collection expressions:

```
public static class MyStaticData
{
    // Doesn't compile (That's good!)
    private static ReadOnlySpan<byte> ReadOnlySpanPruopNonConstantCollectionExpression => [One, 2, 3, 4];

    // Doesn't compile (That's good!)
    private static Span<byte> SpanPropCollectionExpression => [1, 2, 3, 4];
}
```

Attempting to compile this gives `CS9203`

errors:

```
Error CS9203 : A collection expression of type 'ReadOnlySpan<byte>' cannot be used in this context because it may be exposed outside of the current scope.
Error CS9203 : A collection expression of type 'Span<byte>' cannot be used in this context because it may be exposed outside of the current scope.
```

This gives you something of a safety-net. As long as you always use collection expressions for this scenario, you're blocked from making the most egregious errors. The case where you are using `int`

*is* allowed, but as already flagged, that's not *as* bad, because it's actually supported on .NET 7+, and you *still* only create a single instance of the array and cache it in <.NET 7.

Unfortunately, collection expressions *only* save you in the `static`

property case. If you are creating local variables, then collection expressions *don't* save you on .NET Framework (or on any .NET versions <.NET 8)

```
public static class MyStaticData
{
    private static readonly byte One = 1;

    public static void TestData()
    {
        // Oh no, these all allocate on .NET Framework!
        ReadOnlySpan<int> intArray = [1, 2, 3, 4]; // .NET 7+ doesn't allocate for this one

        ReadOnlySpan<byte> nonConstantArray = [One, 2, 3, 4]; // But you need .NET 8+ to avoid
        Span<byte> spanArray = [1, 2, 3, 4];                  // allocations for these two!
    }
}
```

If we take a look at the IL generated for .NET Framework for this method, we can see that the `int[]`

case uses the "create a static array and cache it" approach, while the non-constant and `Span<T>`

cases create a new array every time, the same as happens with a `static`

property:

```
    .method public hidebysig static void
    TestData() cil managed
  {
    .maxstack 5
    .locals init (
      [0] valuetype [System.Memory]System.ReadOnlySpan`1<int32> intArray,
      [1] valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8> nonConstantArray,
      [2] valuetype [System.Memory]System.Span`1<unsigned int8> spanArray
    )

    // [10 5 - 10 6]
    IL_0000: nop

    // Load or initialize the static int[] field data
    IL_0001: ldsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
    IL_0006: dup
    IL_0007: brtrue.s     IL_0021
    IL_0009: pop
    IL_000a: ldc.i4.4
    IL_000b: newarr       [mscorlib]System.Int32
    IL_0010: dup
    IL_0011: ldtoken      field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72
    IL_0016: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
    IL_001b: dup
    IL_001c: stsfld       int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
    IL_0021: newobj       instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])
    IL_0026: stloc.0      // intArray

    // For the non-constant array, a new array is created each time
    IL_0027: ldloca.s     nonConstantArray
    IL_0029: ldc.i4.4
    IL_002a: newarr       [mscorlib]System.Byte
    IL_002f: dup
    IL_0030: ldtoken      field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'
    IL_0035: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
    IL_003a: dup
    IL_003b: ldc.i4.0
    IL_003c: ldsfld       unsigned int8 MyStaticData::One
    IL_0041: stelem.i1
    IL_0042: call         instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])

    // For the `Spam<byte>` array, a new array is created each time
    IL_0047: ldloca.s     spanArray
    IL_0049: ldc.i4.4
    IL_004a: newarr       [mscorlib]System.Byte
    IL_004f: dup
    IL_0050: ldtoken      field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
    IL_0055: call         void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
    IL_005a: call         instance void valuetype [System.Memory]System.Span`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])

    // [15 5 - 15 6]
    IL_005f: ret

  } // end of method MyStaticData::TestData
```

So unfortunately, collection expressions don't save you here. Of course, you likely can (and should) be using `stackalloc`

here for these small arrays, so this isn't *necessarily* a big deal. But you do need to *know* how to do this.

So what should we make of all this?

[Conclusion](#conclusion)

The good news is that if you use the right patterns, using `static ReadOnlySpan<byte>`

properties to replace existing `static readonly byte[]`

fields that contain read-only data *can* give a zero-allocation and essentially zero-startup cost improvement, even on .NET Framework.

However, if the field that you're "converting" is not `byte[]`

, `bool[]`

or `sbyte[]`

, then you should think carefully about whether to convert it. `int[]`

and other types *are* supported for similar optimizations on .NET 7+, but this requires runtime support, so if you're also targeting .NET Framework, .NET Standard, or .NET 6 and below, then I would seriously consider whether it's worth making the change.

You

[likely], but as far as I can tell, you're talking about a ~15% speed improvement for the initial creation of the array. But if you're callingwillsee perf benefits on .NET 7+`RuntimeHelpers.CreateSpan()`

with every access, versus just loading a field, does that actually improve steady state performance? I don't know, I haven't checked, I'm just wondering😄

Where you *really* need to be careful is to *only* use constant values in your arrays (no `static readonly`

values, please) and only use `ReadOnlySpan<T>`

, not `Span<T>`

. Luckily, you'll catch these automatically in your `static`

properties if you're using collection expressions, as they simply won't compile. Which just another reason you should use collection expressions everywhere you can!😃

Replacing `static byte[]`

fields with `static ReadOnlySpan<byte>`

is probably the most common scenario you'll find, but you *can* also apply this to local variables. However, I suspect that scenario is going to be less common, simply because that's so *clearly* very allocating, it means that presumably you "don't care about performance" here, in which case there's no *point* making the `ReadOnlySpan<byte>`

change.

There's another reason for not touching local definitions, which is that the collection expression "solution" described above

doesn'tcause compilation failures with local variables, so there isn't the same easy guardrails there.

If you're anything like me, then the fact that there are so many edge cases where you fall off a performance cliff is somewhat surprising. Generally the .NET team try quite hard to avoid these cliffs, or at least add analyzers to help steer you in the right direction. There seems to be little here to stop you doing the "wrong" thing.

Looking through the various issues and discussions, that's something that's come up multiple times, but it seems like the difficulty is generally "the problematic code patterns are actually valid *sometimes*". There's also the "well, you should be using `stackalloc`

*anyway*" argument, as well as "collection expressions partially protect you":

[[Analyzer]: Unexpected allocation when converting arrays to spans](https://github.com/dotnet/runtime/issues/69577)[[Proposal]: ReadOnlySpan initialization from static data](https://github.com/dotnet/csharplang/issues/5295)[compile-time readonly arrays, const T[]](https://github.com/dotnet/csharplang/discussions/955)["ReadOnlySpan initialization from static data" language design notes](https://github.com/dotnet/csharplang/blob/main/meetings/2023/LDM-2023-10-09.md#readonlyspan-initialization-from-static-data)[[API Proposal]: ReadOnlySpan](https://github.com/dotnet/runtime/issues/60948)CreateSpan (RuntimeFieldHandle)

So all-in-all, this approach seems to be "use at your own risk". I still think it might be nice to have *optional* analyzers at least to try to protect you (and maybe someone's already written those). Nevertheless, the ability to reduce initialization costs to 0 if you have a bunch of static data is definitely a win; just make sure you only use it in a safe way!
