Using the C# Range Operator
c# range operator: Learn how the C# range operator works for slicing arrays, strings, and collections, including syntax, edge cases, and performance considerations.
The C# range operator (..) provides a concise way to specify a range of indices when accessing elements of arrays, strings, and other indexable types. Introduced in C# 8, it works alongside the index operator (^) to give you a more readable alternative to Skip/Take or manual index calculations. This article covers the syntax, how it maps to actual index values, and where it performs well or creates subtle issues.
Range Operator Syntax and Index Mapping
The range operator uses two operands, each of which can be either a regular index (starting at 0) or an index from the end (using the ^ operator). For an array arr, the expression arr[start..end] returns a slice that includes elements from index start up to but not including end. The ^ operator counts from the end: ^1 refers to the last element, ^2 to the second-to-last, and so on.
A range expression can omit either the start or the end. arr[..3] is equivalent to arr[0..3], and arr[3..] goes from index 3 to the end. arr[..] represents the entire collection.
Here is a basic example:
int[] numbers = { 0, 1, 2, 3, 4, 5, 6 }; int[] firstThree = numbers[..3]; // { 0, 1, 2 } int[] fromIndexTwo = numbers[2..]; // { 2, 3, 4, 5, 6 } int[] middle = numbers[2..5]; // { 2, 3, 4 } int[] lastTwo = numbers[^2..]; // { 5, 6 }
Notice that the end index is exclusive. This aligns with the common pattern in C# where ranges exclude the upper bound, making it easy to specify a slice of a given length: arr[i..(i+length)].
The ^ Operator with Ranges
The ^ operator can also appear in the start or end position. arr[^3..^1] gives you a slice that starts three elements from the end and ends one element before the last. For example:
int[] numbers = { 0, 1, 2, 3, 4, 5 }; int[] slice = numbers[^3..^1]; // { 3, 4 }
When mixing ^ with regular indices, the compiler translates each operand into a specific index value at runtime. ^1 becomes length - 1, ^2 becomes length - 2, and so on. This translation happens inside the indexer, so you do not need to pre-calculate the start and end indices manually.
This syntax reduces the risk of off-by-one errors when you are working from the end of a collection, because the counting is explicit and consistent.
Using the Range Operator with Different Types
Beyond arrays, the range operator works with strings, Span<T>, and Memory<T>. For strings, it returns a substring:
string message = "Hello, world"; string greeting = message[..5]; // "Hello" string rest = message[7..]; // "world"
For Span<T> and Memory<T>, the range operator creates a view or segment without copying. That makes it suitable for processing large buffers without allocating extra memory. For example:
byte[] buffer = new byte[1024]; Span<byte> data = buffer.AsSpan(); Span<byte> header = data[..4]; // first four bytes
Be aware that for IList<T> or List<T>, the compiler generates a call to Slice or an equivalent method, which may allocate a new list depending on the type. The behavior is not universal across all collections, so you should verify what happens for a custom type when using the range operator on it.
Implementing Range Support in Custom Types
If you define your own class and want to use the range operator, you have two main options. The simplest is to add a Slice(int start, int length) method, which the compiler will automatically use when it sees obj[start..end]. Alternatively, you can implement an indexer that accepts a Range parameter and returns the appropriate slice.
The Range struct itself is part of the .NET base library. It holds Index objects for the start and end, and you can query Range.All or create a range using the Range constructor. A common pattern is:
public class MyCollection { private int[] _data; public MyCollection(int[] data) => _data = data; public int[] this[Range range] { get { var (start, length) = range.GetOffsetAndLength(_data.Length); return _data.AsSpan(start, length).ToArray(); } } }
The GetOffsetAndLength method translates the Range into concrete start and length values based on the collection's total length. This handles negative or end-based indices correctly and avoids manual index arithmetic.
Common Pitfalls with Variable Ranges
When you use variables to specify a range, be careful about the evaluation order and the values being used. For example, the expression arr[start..end] uses the start and end values at that moment. If you plan to modify the array later, the slice is already a copy (for arrays and strings), so subsequent changes to the array do not affect the slice.
Another common mistake is using a range where the start is greater than the end. This does not throw an error; it returns an empty sequence. That can be surprising if you expected an exception. For example:
int[] numbers = { 1, 2, 3 }; var empty = numbers[2..1]; // empty array
A related issue occurs when the start or end index is outside the array bounds. The runtime throws an ArgumentOutOfRangeException in that case. For Span<T>, though, the behavior is different: it may not validate bounds in the same way, which can lead to undefined behavior if you cross the boundary. Always test with your specific target framework and type.
Performance and Allocation Behavior
The range operator is not free; it creates a new array when used with arrays. The implementation uses Array.Copy under the hood, which is efficient but still allocates memory. If you are in a hot path and want to avoid allocation, prefer Span<T> or Memory<T> with ranges, because they produce no heap allocation.
For lists, the situation is more varied. The List<T> type does not have a native range indexer, so the compiler falls back to GetRange which copies elements into a new list. However, some methods like List<T>.AsSpan enable you to create a span view without copying.
The following table summarizes the allocation behavior for common types:
| Type | Behavior | Allocation |
|---|---|---|
| Array | Copies elements to new array | Yes |
| String | Creates new string | Yes |
| Span<T> | References the same memory | No |
| Memory<T> | References the same memory | No |
| List<T> | Calls GetRange and copies | Yes |
If you are processing large amounts of data, using Span<T> ranges can significantly reduce garbage collection pressure.
Version and Compatibility Considerations
The range operator requires C# 8 or later and .NET Core 3.0 or later (or .NET Standard 2.1). If you are targeting older runtimes, you can still use the syntax if you manually define the Range and Index structs, but that is rarely worth the effort. Most modern projects can safely use it.
One subtlety is that the range operator is not supported for all indexable types. For example, IDictionary does not support it. Also, the compiler may not pick up a Slice method if its signature does not match the expected pattern, so check the method signature (Slice(int start, int length)) and the return type.
Using Ranges in LINQ Queries
While LINQ does not directly use ranges, you can combine the range operator with ToArray() or AsEnumerable() to integrate with LINQ methods. For instance:
var items = collection.ToArray()[1..^1].Where(x => x > 5).ToList();
This extracts the middle elements and applies a filter. However, if collection is already a List<T>, you can avoid the array conversion by using GetRange directly for better performance.
Handling Edge Cases and Defensive Coding
When writing reusable code that accepts a range parameter, validate the range against the collection length. Range.GetOffsetAndLength throws if the resulting offset or length is negative or exceeds the collection length, but it may not catch every misuse. For example, if you pass a range with start = -1 (invalid) or end = length + 1, exceptions will occur later during indexing. You can add explicit checks to produce clearer error messages.
It is also important to remember that ranges are inclusive of the start and exclusive of the end. This consistency helps when chaining operations because you can rely on the length of the resulting slice being exactly end - start when both are non-negative and within bounds.
Range Operator vs. Manual Indexing
In many cases, the range operator is purely syntactic sugar over manual loops or Array.Copy. However, it can reduce accidental off-by-one errors and improve readability. For example, to extract a subarray without the range operator, you would write:
int[] copied = new int[end - start]; Array.Copy(source, start, copied, 0, end - start);
With the range operator, the intent is clearer:
int[] copied = source[start..end];
When readability matters, the range operator is the better choice. When you need to control the exact copying semantics or work with non-indexable types, manual indexing is still necessary.
Advanced Pattern: Range as a Data Structure
The Range struct itself can be stored and passed around. This is useful when you need to defer the slice operation until you have the actual collection. For instance, you can define a method that accepts a Range and applies it to a given array:
public static T[] ApplyRange<T>(T[] items, Range range) { var (offset, length) = range.GetOffsetAndLength(items.Length); return items.AsSpan(offset, length).ToArray(); }
This pattern is especially helpful when parsing data where the same range is applied to multiple collections of the same length, such as processing a header and a payload from a binary stream. Instead of hard-coding indices, you can define the range once and reuse it.
When Not to Use the Range Operator
While the range operator is convenient, it is not always the best tool. If you are working with linked lists or custom iterator-based collections that do not support random access, ranges do not apply. Also, when you need to slice a collection repeatedly and the collection is large, allocating a copy each time may hurt performance. In such cases, consider using Span<T> or exposing a Slice method that returns a view rather than a copy.
Another limitation is that ranges cannot be used with collections that do not have a known length, such as IEnumerable<T>. You must materialize the sequence first, which defeats the lazy iteration benefit. Keep that in mind when designing APIs that accept IEnumerable<T>.
Final Code Example: Reusable Slice Helper
To tie the concepts together, here is a helper that safely extracts a slice from any IList<T> using a Range, falling back to a copy if necessary:
public static List<T> SliceList<T>(IList<T> list, Range range) { var (offset, length) = range.GetOffsetAndLength(list.Count); var result = new List<T>(length); for (int i = 0; i < length; i++) { result.Add(list[offset + i]); } return result; }
This helper uses GetOffsetAndLength to resolve the range against the actual list length, which safely handles ^ operators. It avoids allocating an intermediate array, and it works with any IList<T> implementation, including arrays.