C# String Searching: Contains, IndexOf, Split, Replace, and SearchValues
C# string contains checks, index lookups, splitting, and replacing are among the most frequently used string operations in any .NET application. Whether you are parsing log lines, processing user input, tokenizing text, or extracting fields from a fixed-format record, you will use these APIs constantly.
This guide covers all the core searching and matching APIs -- from the familiar Contains and IndexOf to the powerful SearchValues<char> introduced in .NET 8 for SIMD-vectorized multi-character searching.
Contains: Existence Check
Contains is the simplest way to test whether a substring or character exists anywhere in a string. It is the right starting point for any existence check before reaching for more complex methods:
var text = "The quick brown fox jumps over the lazy dog";
bool hasFox = text.Contains("fox"); // True
bool hasCAT = text.Contains("cat"); // False
bool hasFoxCI = text.Contains("FOX", StringComparison.OrdinalIgnoreCase); // True
Always pass StringComparison when case-insensitivity is needed. The no-parameter overload is case-sensitive and ordinal.
Single-Character Contains
When you only need to test for a single character, the char overload is faster than passing a single-character string because it avoids the substring search setup:
bool hasComma = text.Contains(','); // Faster than text.Contains(",")
bool hasDot = text.Contains('.');
Span-Based Contains
If you already have a ReadOnlySpan<char> and want to avoid allocating a string just for an existence check, the span extension method handles it directly with no heap allocation involved:
ReadOnlySpan<char> span = "Hello, World!".AsSpan();
bool hasCom = span.Contains(",".AsSpan(), StringComparison.Ordinal);
IndexOf and LastIndexOf: Finding Positions
When you need to know WHERE a substring or character appears rather than just whether it exists, IndexOf and LastIndexOf return the zero-based position. Both return -1 when the target is not found, so always check the return value before using it as an index:
var path = "/usr/local/bin/dotnet";
int firstSlash = path.IndexOf('/'); // 0
int lastSlash = path.LastIndexOf('/'); // 14
int dotnetStart = path.IndexOf("dotnet"); // 15
// Search from a specific position
int secondSlash = path.IndexOf('/', 1); // 4 (starting from index 1)
// Search with StringComparison
int pos = path.IndexOf("BIN", StringComparison.OrdinalIgnoreCase); // 11
IndexOfAny and LastIndexOfAny
IndexOfAny finds the first occurrence of any character from a set:
var line = "Name: Nick; Role: Admin";
var delimiters = new[] { ':', ';', ',' };
int firstDelim = line.IndexOfAny(delimiters); // 4 (the ':' after "Name")
int lastDelim = line.LastIndexOfAny(delimiters); // 17 (the ':' after "Role")
This is correct but not the most performant for large strings or hot paths -- see SearchValues below for the high-performance alternative.
Split: Tokenizing Strings
Split divides a string into parts based on a delimiter:
var csv = "alpha,beta,,gamma, delta";
// Basic split
string[] parts = csv.Split(',');
// ["alpha", "beta", "", "gamma", " delta"]
// Remove empty entries
string[] noEmpty = csv.Split(',', StringSplitOptions.RemoveEmptyEntries);
// ["alpha", "beta", "gamma", " delta"]
// Remove empty entries AND trim whitespace (.NET 5+)
string[] cleanParts = csv.Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// ["alpha", "beta", "gamma", "delta"]
TrimEntries (introduced in .NET 5) is a significant quality-of-life improvement. Before it, you had to call LINQ .Select(s => s.Trim()) after splitting.
Split with a Limit
Passing a count argument caps the number of elements returned. When the limit is reached, the final element receives the entire remaining string, including any delimiters that were not yet consumed:
var log = "2026-05-06 21:00:00 INFO Server started";
string[] parts = log.Split(' ', 4); // ["2026-05-06", "21:00:00", "INFO", "Server started"]
The last element contains the remainder of the string, including any additional delimiters.
Split with Multiple Delimiters
When the input uses several different separator characters, you can pass an array of chars or strings. This is common for parsing loosely formatted text where fields may be separated by commas, pipes, or semicolons depending on the source: var text = "one|two,three;four"; string[] parts = text.Split(new[] { '|', ',', ';' }); // ["one", "two", "three", "four"]
// Or with a string array string[] stringParts = text.Split(new[] { "|", ",", ";" }, StringSplitOptions.None);
### Span-Based Splitting (.NET 6+)
For zero-allocation splitting in high-performance scenarios, use `MemoryExtensions.Split`:
```csharp
// .NET 8+ -- Span<Range> split
var line = "alpha,beta,gamma";
Span<Range> ranges = stackalloc Range[10];
int count = line.AsSpan().Split(ranges, ',');
for (var i = 0; i < count; i++)
{
var part = line.AsSpan(ranges[i]); // No allocation per field
Console.WriteLine(part.ToString());
}
This is the allocation-free approach -- you get Range values that point into the original string, avoiding any intermediate string creation.
Replace: Substitution
Replace returns a new string with all occurrences of a value replaced:
var text = "Hello, World! World!";
// Replace string
string replaced = text.Replace("World", "Nick");
// "Hello, Nick! Nick!"
// Replace char
string noCommas = text.Replace(',', ';');
// Case-insensitive replace (.NET 5+)
string ciReplaced = text.Replace("WORLD", "Nick", StringComparison.OrdinalIgnoreCase);
// "Hello, Nick! Nick!"
Note that Replace allocates a new string even if no replacement occurs when the substring is not found. For hot paths, consider checking with Contains first:
// Avoid allocation if no replacement needed
var result = text.Contains("World", StringComparison.OrdinalIgnoreCase)
? text.Replace("World", "Nick", StringComparison.OrdinalIgnoreCase)
: text;
Substring vs Range Operator
Substring extracts a portion of a string. The range operator [..] (C# 8+) is the modern, more readable alternative:
var text = "Hello, World!";
// Classic Substring
string sub1 = text.Substring(7, 5); // "World"
string sub2 = text.Substring(7); // "World!"
// Range operator (C# 8+)
string ranged1 = text[7..12]; // "World"
string ranged2 = text[7..]; // "World!"
string ranged3 = text[..5]; // "Hello"
string last4 = text[^4..]; // "ld!" (^ means from end)
The range operator is syntactic sugar -- both approaches allocate a new string. Use AsSpan() with a range for a zero-allocation slice:
ReadOnlySpan<char> span = text.AsSpan(7, 5); // Zero allocation
ReadOnlySpan<char> rangeSpan = text.AsSpan()[7..12]; // Also zero allocation
SearchValues (.NET 8+): SIMD-Vectorized Search
SearchValues<T> is the high-performance solution for searching strings for any character from a set. It uses platform-specific SIMD instructions (SSE2, AVX2, or NEON on ARM) to scan multiple characters at once -- making it dramatically faster than IndexOfAny with a char array on long strings.
using System.Buffers;
namespace StringDemo;
public static class Tokenizer
{
// Create once, reuse many times -- construction has a one-time cost
private static readonly SearchValues<char> _whitespace =
SearchValues.Create("
");
private static readonly SearchValues<char> _urlSpecialChars =
SearchValues.Create("/?#%&=+@");
private static readonly SearchValues<char> _htmlSpecialChars =
SearchValues.Create("<>&"'");
public static int FindFirstWhitespace(ReadOnlySpan<char> text)
{
return text.IndexOfAny(_whitespace);
}
public static bool ContainsUrlSpecialChar(ReadOnlySpan<char> url)
{
return url.IndexOfAny(_urlSpecialChars) >= 0;
}
public static bool NeedsHtmlEncoding(ReadOnlySpan<char> text)
{
return text.IndexOfAny(_htmlSpecialChars) >= 0;
}
}
SearchValues is declared as a static readonly field because its construction involves building optimized internal data structures. The cost is paid once, at startup or on first use.
SearchValues (.NET 9+)
.NET 9 extends the SearchValues concept from single characters to full strings, enabling simultaneous multi-pattern searching via Aho-Corasick matching. This is useful when you need to detect any of several keywords in a stream of text:
// .NET 9+
SearchValues<string> httpMethods = SearchValues.Create(
["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
StringComparison.OrdinalIgnoreCase);
bool isHttpMethod = "get".AsSpan().ContainsAny(httpMethods); // True
This uses Aho-Corasick pattern matching internally for efficient simultaneous multi-pattern searching.
Practical Example: Log Line Parser
The following parser combines SearchValues, Split, and span-based operations into a complete, allocation-conscious pipeline. It first does a fast pre-check with SearchValues to skip lines that cannot possibly contain a log level before running the more expensive Split call:
using System.Buffers;
namespace StringDemo;
public readonly record struct LogEntry(
string Timestamp,
string Level,
string Message);
public static class LogParser
{
private static readonly SearchValues<char> _levelChars =
SearchValues.Create("DIWEF"); // Debug, Info, Warn, Error, Fatal
public static LogEntry? Parse(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return null;
}
// Fast pre-check: must contain a level character
if (line.AsSpan().IndexOfAny(_levelChars) < 0)
{
return null;
}
// Format: "2026-05-06 21:00:00 INFO Message content here"
var parts = line.Split(' ', 4, StringSplitOptions.TrimEntries);
if (parts.Length < 4)
{
return null;
}
var timestamp = $"{parts[0]} {parts[1]}";
var level = parts[2];
var message = parts[3];
return new LogEntry(timestamp, level, message);
}
public static IEnumerable<LogEntry> ParseLines(IEnumerable<string> lines)
{
foreach (var line in lines)
{
var entry = Parse(line);
if (entry.HasValue)
{
yield return entry.Value;
}
}
}
}
Practical Example: Simple Template Engine
Replace can be used to build a minimal named-variable template engine. The following implementation substitutes {Key} placeholders with values from a dictionary, using case-insensitive matching so templates are resilient to casing differences in variable names:
namespace StringDemo;
public static class TemplateEngine { public static string Render( string template, IReadOnlyDictionary<string, string> variables) { var result = template; foreach (var (key, value) in variables) }}", value, StringComparison.OrdinalIgnoreCase); }
return result;
}
}
// Usage var template = "Hello, ! You have messages."; var vars = new Dictionary<string, string> { { "Name", "Nick" }, { "Count", "42" } }; var rendered = TemplateEngine.Render(template, vars); // "Hello, Nick! You have 42 messages."
---
## String Searching in the Broader .NET Ecosystem
Efficient string searching matters across many areas of .NET development. When building [feature-sliced applications in C#](https://www.devleader.ca/2026/04/15/feature-slicing-in-c-organizing-code-by-feature), you often route requests by parsing string-based endpoints or command names -- efficient searching reduces latency in hot paths.
The [CQRS with feature slices pattern](https://www.devleader.ca/2026/04/25/cqrs-with-feature-slices-in-c-commands-and-queries-per-feature) involves dispatching commands by type name or string key. Using `SearchValues` or `FrozenDictionary` for these dispatches can meaningfully improve throughput.
When implementing [plugin architectures in C#](https://www.devleader.ca/2026/04/07/plugin-architecture-in-c-the-complete-guide-to-extensible-net-applications), plugin discovery often involves scanning assembly names or interface names for matches. Efficient string matching with `OrdinalIgnoreCase` and `Contains`/`IndexOf` makes plugin loading robust and fast.
For AI features, [building a semantic search engine with Semantic Kernel in C#](https://www.devleader.ca/2026/03/18/build-a-semantic-search-engine-with-semantic-kernel-in-c) shows how semantic search differs from lexical string searching -- but the string preprocessing that feeds the embedding pipeline still relies on `Split`, `Trim`, and `Replace`.
If you are working with [C# enums](https://www.devleader.ca/2026/04/26/c-enum-complete-guide-to-enumerations-in-net) and need to parse enum values from user input, `string.Contains` and `IndexOf` are useful for fuzzy matching before calling `Enum.TryParse`.
---
## Performance Comparison
Choosing the right API often comes down to whether you need existence, position, or a full substring extraction -- and how much allocation is acceptable in your context. This table summarizes the key tradeoffs across the APIs covered in this guide:
|--------|-----------|------------------|
| `Contains(string)` | No | O(N*M) worst case |
| `IndexOf(char)` | No | Vectorized in .NET 6+ |
| `IndexOfAny(char[])` | No | Slower than `SearchValues` |
| `SearchValues.IndexOfAny` | No | SIMD vectorized, fastest for multi-char |
| `Split(char)` | Yes (array) | Use Span split for zero-alloc |
| `Replace(string, string)` | Yes (new string) | Skip if no match found |
| `Substring` / `[..]` | Yes (new string) | Use `AsSpan()` for zero-alloc |
---
## FAQ
These are the most common questions developers ask when working with C# string searching. The answers focus on correctness and performance considerations that are easy to miss when starting out:
`Contains` returns a `bool` (found or not). `IndexOf` returns the position (or -1). Use `Contains` when you only need existence; use `IndexOf` when you need the position for subsequent operations.
### Does Contains allocate memory in C#?
No. `string.Contains(string)` and `string.Contains(char)` do not allocate. They scan the existing string without creating new objects. However, methods like `ToLower()` used for case-insensitive comparison do allocate -- prefer the `StringComparison` overload instead.
### What is SearchValues<char> in .NET 8?
`SearchValues<char>` is a pre-computed, SIMD-optimized data structure for searching strings for any character from a set. It is used with `IndexOfAny`, `ContainsAny`, and `IndexOfAnyExcept` extension methods on `ReadOnlySpan<char>`. It is significantly faster than `IndexOfAny(char[])` because it uses platform-specific vector instructions.
### How do I split a string without allocating in C#?
Use `MemoryExtensions.Split(ReadOnlySpan<char>, Span<Range>, char)` (available in .NET 8+). It fills a `Span<Range>` with range values pointing into the original string -- no intermediate strings are created. You can then use `AsSpan(range)` to access each part.
### Is Replace case-insensitive in C#?
By default, `Replace` is case-sensitive and will not substitute substrings that differ in casing from the search value. To perform case-insensitive replacement, use the three-parameter overload introduced in .NET 5: `Replace(string oldValue, string? newValue, StringComparison.OrdinalIgnoreCase)`. The older two-parameter overload has no `StringComparison` parameter and always treats the search as case-sensitive.
### When should I use SearchValues vs IndexOfAny with a char array?
Use `SearchValues<char>` when the set of characters to search for is known at compile time or startup, and the search will be performed many times. Declare it as `static readonly`. Use the char array overload for one-off or rarely used searches where the construction overhead of `SearchValues` is not justified.
### What is the TrimEntries option in string.Split?
`StringSplitOptions.TrimEntries` (introduced in .NET 5) automatically trims whitespace from each part after splitting. Before .NET 5, you had to call `.Select(s => s.Trim())` on the result. Combining it with `RemoveEmptyEntries` is the most common pattern: `Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)`.

