You have a hot path that scans text for a set of characters — maybe you're sanitising log entries, tokenising input, or validating that a string only contains expected characters. You write a quick IndexOfAny call, ship it, and move on. Months later a profiler tells you that innocent-looking call is burning measurable CPU across thousands of requests per second.

The problem is not that IndexOfAny is slow. The problem is that every time you call it with a char[] or a string literal, the runtime has to re-examine your search set, pick a strategy, and execute it — even though the values never change. SearchValues<T>, introduced in .NET 8 and expanded in .NET 9, solves this by separating the strategy selection from the search itself.

You create a SearchValues<T> instance once, typically in a static readonly field. At creation time, the runtime inspects your values, considers the hardware it's running on, and locks in the fastest available algorithm — SIMD vectorisation, bitmap lookups, range checks, or specialised single-value paths. Every subsequent search reuses that decision with zero repeated analysis.

The basics

SearchValues<T> lives in System.Buffers and has three Create overloads:

Example.cs
// Characters (since .NET 8)
SearchValues<char> chars = SearchValues.Create("abcdef");

// Bytes (since .NET 8)
SearchValues<byte> bytes = SearchValues.Create(new byte[] { 0x0A, 0x0D, 0x00 });

// Strings with comparison rules (since .NET 9)
SearchValues<string> keywords = SearchValues.Create(
    ["SELECT", "INSERT", "DELETE", "DROP"],
    StringComparison.OrdinalIgnoreCase);

Once created, you use the instance with the familiar Span<T> and ReadOnlySpan<T> extension methods on MemoryExtensions:

Example.cs
private static readonly SearchValues<char> s_whitespace = SearchValues.Create(" \t\r\n");

public static int FindFirstWhitespace(ReadOnlySpan<char> text)
{
    return text.IndexOfAny(s_whitespace);
}

The API surface is deliberately minimal — SearchValues<T> is not a collection you iterate over. It is an opaque, immutable search accelerator.

What the runtime does at creation time

When you call SearchValues.Create, the implementation does not simply store your values and move on. It runs a decision tree that considers:

This is the trade-off: creation is more expensive than a plain IndexOfAny call, but every subsequent search is faster. The break-even point is low — typically a handful of calls — and in any hot path the amortised cost is negligible.

Character searching in practice

The most common use case is scanning text for a known set of characters that never changes at runtime. Consider a simple HTML encoder that needs to find the first character requiring escaping:

HtmlEncoder.cs
public static class FastHtmlEncoder
{
    private static readonly SearchValues<char> s_charsToEscape =
        SearchValues.Create("<>&\"'");

    public static string Encode(ReadOnlySpan<char> input)
    {
        int index = input.IndexOfAny(s_charsToEscape);

        if (index < 0)
            return input.ToString(); // Nothing to escape — fast path

        return EncodeSlowPath(input, index);
    }

    private static string EncodeSlowPath(ReadOnlySpan<char> input, int firstEscapeIndex)
    {
        var builder = new ValueStringBuilder(stackalloc char[256]);
        builder.Append(input[..firstEscapeIndex]);

        for (int i = firstEscapeIndex; i < input.Length; i++)
        {
            char c = input[i];
            string? replacement = c switch
            {
                '<' => "&lt;",
                '>' => "&gt;",
                '&' => "&amp;",
                '"' => "&quot;",
                '\'' => "&#39;",
                _ => null
            };

            if (replacement is not null)
                builder.Append(replacement);
            else
                builder.Append(c);
        }

        return builder.ToString();
    }
}

The fast path — where no escaping is needed — is now a single SIMD-accelerated scan of the entire input. No allocations, no per-character branching, no repeated strategy selection.

Validating allowed characters

IndexOfAnyExcept is the inverse: it finds the first character that is not in your set. This is perfect for input validation:

InputValidator.cs
public static class InputValidator
{
    private static readonly SearchValues<char> s_allowedInUsername =
        SearchValues.Create("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.");

    public static bool IsValidUsername(ReadOnlySpan<char> username)
    {
        return username.Length is > 0 and <= 64
            && username.IndexOfAnyExcept(s_allowedInUsername) < 0;
    }
}

Because the alphanumeric characters and a few symbols form a largely contiguous set, the runtime is likely to use a range-check or bitmap strategy that processes many characters per CPU cycle.

Multi-string searching with .NET 9

.NET 9 added SearchValues.Create(ReadOnlySpan<string>, StringComparison), which extends the concept from individual characters to entire substrings. The runtime builds an optimised multi-pattern matcher — no need to roll your own state machine or pull in a third-party Aho-Corasick library.

ContentModerator.cs
public static class ContentModerator
{
    private static readonly SearchValues<string> s_blockedTerms =
        SearchValues.Create(
            ["phishing", "malware", "credential harvest", "bitcoin transfer"],
            StringComparison.OrdinalIgnoreCase);

    public static bool ContainsBlockedContent(ReadOnlySpan<char> text)
    {
        return text.ContainsAny(s_blockedTerms);
    }
}

The StringComparison parameter is required. Ordinal and OrdinalIgnoreCase are the supported values — culture-aware comparisons are not available here, which makes sense given the byte-level optimisation strategies involved.

// TIP

If you need the index of the match rather than a boolean, use MemoryExtensions.IndexOfAny(ReadOnlySpan<char>, SearchValues<string>). It returns the position of the first matching substring.

The full method family

SearchValues<T> integrates with a broad set of extension methods on MemoryExtensions. Here is the complete list for SearchValues<char>:

Method Description
IndexOfAny First index of any value in the set
IndexOfAnyExcept First index of any value not in the set
LastIndexOfAny Last index of any value in the set
LastIndexOfAnyExcept Last index of any value not in the set
ContainsAny Whether the span contains any value in the set
ContainsAnyExcept Whether the span contains any value not in the set

For SearchValues<string>, ContainsAny and IndexOfAny are available.

All of these methods work on both Span<T> and ReadOnlySpan<T>, so they compose naturally with slicing, string.AsSpan(), and zero-allocation parsing patterns.

The CA1870 analyser rule

The .NET SDK includes analyser rule CA1870, which detects calls to IndexOfAny or ContainsAny that pass more than five constant values directly. It suggests extracting those values into a cached SearchValues<T> instance instead.

The rule exists because calls with five or fewer values already use an optimised code path internally. Beyond five values, a cached SearchValues<T> instance provides a measurable improvement.

Example.cs
// Bad — re-examines the search set on every call
static int FindSpecial(ReadOnlySpan<char> text)
{
    return text.IndexOfAny("!@#$%^&*()");
}

// Good — strategy selected once, reused on every call
private static readonly SearchValues<char> s_special = SearchValues.Create("!@#$%^&*()");

static int FindSpecial(ReadOnlySpan<char> text)
{
    return text.IndexOfAny(s_special);
}

// NOTE

CA1870 ships as a suggestion-level diagnostic in .NET 10. Consider promoting it to a warning in your .editorconfig if you have performance-sensitive code paths.

Performance characteristics

The exact speedup depends on the hardware, the size of the search set, and the length of the input. Some representative patterns:

In production scenarios with high request volumes, the compound effect is significant. Teams have reported 5x throughput improvements on log sanitisation workloads after switching to SearchValues<char>, with corresponding reductions in CPU utilisation and infrastructure cost.

Common pitfalls

Creating instances in method bodies. The entire point of SearchValues<T> is amortising the creation cost. If you create a new instance per call, you pay the analysis overhead every time and gain nothing. Always use static readonly fields.

Example.cs
// Bad — pays the analysis cost on every call
public bool HasVowel(ReadOnlySpan<char> text)
{
    var vowels = SearchValues.Create("aeiouAEIOU");
    return text.ContainsAny(vowels);
}

// Good — analysis happens once at type initialisation
private static readonly SearchValues<char> s_vowels = SearchValues.Create("aeiouAEIOU");

public bool HasVowel(ReadOnlySpan<char> text)
{
    return text.ContainsAny(s_vowels);
}

Using it for tiny, one-off searches. If you search for two or three characters in a method that runs once during startup, SearchValues<T> adds complexity for no measurable benefit. The optimised small-set paths in IndexOfAny already handle this well.

Expecting culture-aware string matching. SearchValues<string> only supports Ordinal and OrdinalIgnoreCase. If you need linguistic comparisons, you will need a different approach.

Forgetting the Except variants. A surprisingly common pattern is validating that a string contains only allowed characters. Developers often write a manual loop checking each character against a set. IndexOfAnyExcept with a SearchValues<char> instance is both cleaner and faster.

Passing dynamic values that change at runtime. SearchValues<T> is immutable. If your search set changes, you need to create a new instance. This is fine if it happens rarely (configuration reload, for example), but if the set changes per-request, the creation overhead will dominate.

Summary