The C# String API in Detail: Searching, Splitting, Joining, Trimming and Replacing
IndexOf, StartsWith and EndsWith with a string argument are culture-sensitive by default, while the char overloads, Contains, Replace and Split are ordinal, so machine data should always get StringComparison.Ordinal. Through Bookstore's slug and ISBN normalization code we show Split options, Join versus +=, AsSpan, SearchValues, CompositeFormat and the .NET 11 Rune overloads, all with real output.
Version note: The code in this post was built and run with .NET 10; the example in the Rune overloads section was run with the .NET 11 SDK.
Guess First
In the file that Bookstore, the online bookstore used as the shared example across this site's series, receives from its old accounting system, the book title sits in a fixed-width field padded with null characters (\0) instead of spaces. What do these three lines print?
// A fixed-width field from the old accounting system, padded with null characters.
string field = "Dune\0\0\0\0";
Console.WriteLine(field.IndexOf('\0'));
Console.WriteLine(field.IndexOf("\0"));
Console.WriteLine("Dune,,Emma,".Split(',').Length);
Most people answer 4 for the first two and 2 or 3 for the third. The real output:
4
0
4
We search for the same character; passing a char gives 4, passing a string gives 0. On the third line Split also counts the empty pieces: "Dune", "", "Emma", "". This post explains where differences like these come from and which overload you should pick for the string methods you write every day.
The C# Fundamentals post "Strings in C#: Interpolation, StringBuilder and the Turkish I Trap" covered string immutability and the basic methods. Here we go one step further: the overloads and defaults of the searching, splitting, joining, trimming and replacing methods, plus the .NET 8 APIs that cut allocations in repeated work. Regular expressions are not part of this series; for searching and validating with patterns, see the Regular Expressions series. All examples live in the Bookstore.Bcl.Samples console app in the series repo:
dotnet run --project samples/Bookstore.Bcl.Samples -- strings-search --culture en-US
Searching: IndexOf, Contains, StartsWith and the Default Comparison
Some of the methods that search inside a string use an ordinal comparison by default, which compares the numeric values of characters one by one, and others use a culture-sensitive, linguistic comparison. Which is which is hard to memorize, because the split does not follow method names, it follows overloads:
| Call | Default comparison |
|---|---|
IndexOf(char), LastIndexOf(char), StartsWith(char) | Ordinal |
Contains(string), Replace(string, string), Split | Ordinal |
Equals, == | Ordinal |
IndexOf(string), LastIndexOf(string) | CurrentCulture |
StartsWith(string), EndsWith(string) | CurrentCulture |
string.Compare, CompareTo | CurrentCulture |
Since .NET 5, culture-sensitive comparison is done by ICU on every platform. Under ICU's rules some characters have zero weight: the null character, the soft hyphen U+00AD, which arrives invisibly with text copied from a web page, and zero-width characters are ignored when comparing. Let's search the same field, and a book title that arrived through copy and paste, with different overloads:
string field = "Dune\0\0\0\0";
Row("IndexOf('\\0')", field.IndexOf('\0'));
Row("IndexOf(\"\\0\")", field.IndexOf("\0"));
Row("IndexOf(\"\\0\", Ordinal)", field.IndexOf("\0", StringComparison.Ordinal));
Row("Contains(\"\\0\")", field.Contains("\0"));
Row("Replace(\"\\0\", \"\")", $"[{field.Replace("\0", "")}]");
// U+00AD soft hyphen: invisible, comes along with text copied from a web page.
string pasted = "Middle\u00ADmarch";
Row("StartsWith(\"Middlemarch\")", pasted.StartsWith("Middlemarch"));
Row("StartsWith(..., Ordinal)", pasted.StartsWith("Middlemarch", StringComparison.Ordinal));
Row("Equals(\"Middlemarch\")", pasted.Equals("Middlemarch"));
Row("Contains(\"Middlemarch\")", pasted.Contains("Middlemarch"));
static void Row(string label, object value) => Console.WriteLine($"{label,-26}: {value}");
IndexOf('\0') : 4
IndexOf("\0") : 0
IndexOf("\0", Ordinal) : 4
Contains("\0") : True
Replace("\0", "") : [Dune]
StartsWith("Middlemarch") : True
StartsWith(..., Ordinal) : False
Equals("Middlemarch") : False
Contains("Middlemarch") : False
IndexOf("\0") searches with culture rules, so it treats the zero-weight character as "present everywhere" and returns 0. Contains and Replace are ordinal and behave correctly. The second group is sneakier: StartsWith ignores the invisible hyphen and says True, while Equals and Contains say False for the same string. If one part of your code decides "this book already exists" with StartsWith and another part can't find it in a dictionary, this is why.
When the same program runs in invariant globalization mode (DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, ICU is not loaded), the culture-sensitive calls behave ordinally too: IndexOf("\0") returns 4 and StartsWith("Middlemarch") returns False. So the same code can give different results depending on the container image; we look at this in detail in the third post of the series.
The culture difference is even more visible in case-insensitive search. I ran code that looks for "hobbit" inside "THE HOBBIT" under three cultures:
string title = "THE HOBBIT";
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} CurrentCultureIgnoreCase: {title.IndexOf("hobbit", StringComparison.CurrentCultureIgnoreCase)}");
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} OrdinalIgnoreCase : {title.IndexOf("hobbit", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} ToLower() : {title.ToLower()}");
tr-TR CurrentCultureIgnoreCase: -1
tr-TR OrdinalIgnoreCase : 4
tr-TR ToLower() : the hobbıt
en-US CurrentCultureIgnoreCase: 4
en-US OrdinalIgnoreCase : 4
en-US ToLower() : the hobbit
CurrentCultureIgnoreCase: 4
OrdinalIgnoreCase : 4
ToLower() : the hobbit
In Turkish the lowercase of I is ı, so under tr-TR "HOBBIT" and "hobbit" don't match. The last three lines start with nothing because the invariant culture's name is an empty string. The rule is simple: for machine data such as book titles, ISBNs, order numbers, file paths and HTTP headers, use StringComparison.Ordinal or OrdinalIgnoreCase. Culture-sensitive comparison can be the right choice when you sort text shown to users or search in the user's language, but then pass the culture explicitly.
Split: RemoveEmptyEntries and TrimEntries
Institutional customers type book titles into a single comma-separated field on the order form. Since it's user input, spaces and extra commas are always there:
string input = "Dune, Emma,, The Hobbit , ";
Show("Split(',')", input.Split(','));
Show("RemoveEmptyEntries", input.Split(',', StringSplitOptions.RemoveEmptyEntries));
Show("TrimEntries", input.Split(',', StringSplitOptions.TrimEntries));
Show("Remove | Trim", input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
Show("count: 2", "BS-2026-0001234".Split('-', 2));
Show("string separator", "Dune -- Emma -- Middlemarch".Split(" -- "));
Show("any of ; and |", "Dune;Emma|Middlemarch".Split([';', '|']));
Split(',') 5 -> ["Dune" | " Emma" | "" | " The Hobbit " | " "]
RemoveEmptyEntries 4 -> ["Dune" | " Emma" | " The Hobbit " | " "]
TrimEntries 5 -> ["Dune" | "Emma" | "" | "The Hobbit" | ""]
Remove | Trim 3 -> ["Dune" | "Emma" | "The Hobbit"]
count: 2 2 -> ["BS" | "2026-0001234"]
string separator 3 -> ["Dune" | "Emma" | "Middlemarch"]
any of ; and | 3 -> ["Dune" | "Emma" | "Middlemarch"]
RemoveEmptyEntries alone drops only zero-length pieces; " " doesn't count as empty and stays in the list. TrimEntries, added in .NET 5, trims every piece but doesn't remove a piece that becomes empty after trimming. When you pass both, trimming happens first and empty pieces are removed afterwards, leaving three clean titles. When splitting user input this combination is almost always what you want.
The count parameter caps the number of pieces; the last piece carries everything that's left. It's handy for separating just the prefix of an order number. Split also accepts a string separator and several characters. One thing it can't know about is a comma inside quotes. You can't split a real CSV line with a value like "Lovelace, Ada" using Split. That topic is covered in the "Working with CSV" post of the I/O in .NET series.
Joining: Join, Concat, StringBuilder and string.Create
Because strings are immutable, += in a loop creates a new string at every step that copies the whole previous one. To see this as a number rather than a feeling, I joined a thousand book titles in three ways and measured the allocated memory with GC.GetAllocatedBytesForCurrentThread:
string[] titles = Enumerable.Range(1, 1_000).Select(i => $"Book {i:D4}").ToArray();
long before = GC.GetAllocatedBytesForCurrentThread();
string csv = "";
foreach (string title in titles)
csv += title + ",";
long concatLoop = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
var builder = new StringBuilder();
foreach (string title in titles)
builder.Append(title).Append(',');
string built = builder.ToString();
long stringBuilder = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
string joined = string.Join(',', titles);
long join = GC.GetAllocatedBytesForCurrentThread() - before;
+= in a loop : 10,036,000 bytes, length 10000
StringBuilder : 53,200 bytes, length 10000
string.Join : 20,024 bytes, length 9999
The result is a 10,000-character string; += allocates about 10 MB to get there, StringBuilder 53 KB and string.Join 20 KB. Join allocates the least because it computes the total length first and creates a string of the right size in one go. It also doesn't add a trailing separator, which is why the length is 9999. If you have a collection, use string.Join; if you add pieces conditionally, use StringBuilder. For putting a few pieces side by side, string.Concat or interpolation is enough; the compiler already turns those into a single call.
For a string whose length is known up front there is one more option: string.Create. It hands you the string's own memory as a Span<char> and you fill it in, with no intermediate buffer:
int orderNo = 1234;
string viaCreate = string.Create(10, orderNo, static (span, number) =>
{
"BS-".CopyTo(span);
number.TryFormat(span[3..], out _, "D7", CultureInfo.InvariantCulture);
});
Console.WriteLine(viaCreate);
BS-0001234
To be fair, in this example $"BS-{orderNo:D7}" also allocates a single string; since C# 10, interpolation writes into a pooled buffer and produces one string at the end. string.Create makes a difference when you compute the characters yourself: hex encoding, masking, a custom ordering. The static lambda and the state parameter also avoid a closure allocation. Spans and allocation-free parsing are covered in the C# Advanced post "Span<T> and Memory<T> in C#".
Trimming, Padding and Replacing
The parameterless Trim methods remove every character Unicode considers whitespace (space, tab, line breaks, non-breaking space). If you pass a list of characters, they remove only those:
Console.WriteLine($"[{" Dune \t\r\n".Trim()}]");
Console.WriteLine($"[{"--Dune..".Trim('-', '.')}]");
Console.WriteLine($"[{"/books/dune/".TrimEnd('/')}]");
Console.WriteLine($"[{"000123".TrimStart('0')}]");
Console.WriteLine($"[{"123".PadLeft(7, '0')}]");
Console.WriteLine($"[{"Dune".PadRight(12)}|{"Emma".PadLeft(8)}]");
string note = "Gift wrap, GIFT card, gift note";
Console.WriteLine(note.Replace("gift", "present"));
Console.WriteLine(note.Replace("gift", "present", StringComparison.OrdinalIgnoreCase));
Console.WriteLine("978-0-441-17271-9".Replace("-", ""));
Console.WriteLine("Line1\r\nLine2\nLine3".ReplaceLineEndings(" | "));
Console.WriteLine("BS0001234".Insert(2, "-"));
Console.WriteLine("BS-0001234-DRAFT".Remove(10));
Console.WriteLine("Dune (Ace, 1990)".Remove(4, 12));
[Dune]
[Dune]
[/books/dune]
[123]
[0000123]
[Dune | Emma]
Gift wrap, GIFT card, present note
present wrap, present card, present note
9780441172719
Line1 | Line2 | Line3
BS-0001234
BS-0001234
Dune
A few things to watch. Trim('0') is not a "remove leading zeros" method; "000" becomes an empty string, and if you are converting to a number, int.Parse accepts leading zeros anyway. Replace(string, string) is ordinal and case-sensitive; on the first line only the lowercase "gift" changed. For case-insensitive replacement there has been an overload taking StringComparison since .NET Core 2.0. To normalize line endings coming from different operating systems, ReplaceLineEndings from .NET 6 recognizes \r\n, \n, \r and the Unicode line separators. PadLeft and PadRight are useful for console tables, but for zero-padding a number a format string (D7) is the better tool because it puts the sign in the right place for negative numbers. Format strings are the topic of the next post.
Range and AsSpan Instead of Substring
Substring allocates a new string on every call. C# 8's range syntax helps readability, and AsSpan avoids the allocation:
string orderNumber = "BS-2026-0001234";
string year1 = orderNumber.Substring(3, 4);
string year2 = orderNumber[3..7];
ReadOnlySpan<char> yearSpan = orderNumber.AsSpan(3, 4);
string last = orderNumber[^7..];
long before = GC.GetAllocatedBytesForCurrentThread();
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += int.Parse(orderNumber.Substring(8));
long withSubstring = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
sum += int.Parse(orderNumber.AsSpan(8));
long withSpan = GC.GetAllocatedBytesForCurrentThread() - before;
2026 2026 2026 0001234
Substring + int.Parse : 400,928 bytes
AsSpan + int.Parse : 0 bytes
orderNumber[3..7] compiles down to a Substring call, so there is no difference in allocations; the gain is readability ([^7..] means "the last seven characters"). AsSpan returns a window over the string's memory and allocates nothing. Methods such as int.Parse, decimal.Parse, Guid.Parse and DateTime.ParseExact have had ReadOnlySpan<char> overloads since .NET Core 2.1. 400 KB of garbage for ten thousand parses means the GC runs more often in an import job that processes thousands of lines per second.
Bookstore: Slugs from Titles, ISBNs from Input
Every book's URL in the catalog ends with a slug built from its title. The first solution that comes to mind is ToLower().Replace(" ", "-"). Let's see what happens with Turkish titles:
foreach (string title in titles)
{
Console.WriteLine($"tr-TR naive : {title.ToLower(tr).Replace(" ", "-")}");
Console.WriteLine($"en-US naive : {title.ToLower(en).Replace(" ", "-")}");
Console.WriteLine($"Slug : {Slug.FromTitle(title)}");
}
tr-TR naive : kürk-mantolu-madonna
en-US naive : kürk-mantolu-madonna
Slug : kurk-mantolu-madonna
tr-TR naive : ince-memed
en-US naive : ince-memed
Slug : ince-memed
tr-TR naive : ıstanbul-hatıralar-ve-şehir
en-US naive : istanbul-hatiralar-ve-şehir
Slug : istanbul-hatiralar-ve-sehir
tr-TR naive : the-hobbit:-or-there-and-back-again
en-US naive : the-hobbit:-or-there-and-back-again
Slug : the-hobbit-or-there-and-back-again
tr-TR naive : --dune----part-1-
en-US naive : --dune----part-1-
Slug : dune-part-1
An editor who types the title in capitals in the admin panel gets an ıstanbul or an istanbul slug depending on the server's culture, and the same book can end up with two different URLs. The correct solution has two steps: use Unicode normalization to separate a letter from the mark on top of it, then keep only ASCII letters and digits:
public static string FromTitle(string title, int maxLength = 80)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
// FormD splits "ü" into "u" + U+0308 (combining diaeresis); the mark is dropped below.
string decomposed = title.Normalize(NormalizationForm.FormD);
var slug = new StringBuilder(Math.Min(decomposed.Length, maxLength));
bool pendingDash = false;
foreach (char c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
continue;
// Dotless i has no decomposition, so it is mapped by hand.
char lower = c == 'ı' ? 'i' : char.ToLowerInvariant(c);
if (char.IsAsciiLetterOrDigit(lower))
{
int needed = pendingDash && slug.Length > 0 ? 2 : 1;
if (slug.Length + needed > maxLength)
break;
if (needed == 2)
slug.Append('-');
slug.Append(lower);
pendingDash = false;
}
else
{
pendingDash = true;
}
}
return slug.ToString();
}
NormalizationForm.FormD splits "ü" into "u" plus a combining diaeresis, "ş" into "s" plus a cedilla and "İ" into "I" plus a combining dot above; once the marks in the NonSpacingMark category are dropped, an ASCII letter remains. The only Turkish exception is ı: Unicode has no decomposition for it, so we map it by hand. ToLowerInvariant is independent of culture, so the server's settings can't change the result. Anything that isn't a letter or digit collapses into a single hyphen, and leading and trailing hyphens are never written. The maxLength limit can cut in the middle of a word (saatleri-ayarlama-en); that's acceptable for a URL, or you can back up to the last hyphen. Letters that don't decompose, such as German "ß" or Danish "ø", are dropped by this code; a multilingual catalog needs a mapping table for them too.
The second problem is the ISBN. Institutional customers write the ISBN in the order spreadsheet in every possible way: "ISBN 978-0-441-17271-9", " isbn: 978 0441 172719 ", "9780441172719". A method that reduces all of them to 13 digits and validates the check digit:
private static readonly SearchValues<char> Separators = SearchValues.Create(" -");
public static bool TryNormalize(string? input, [NotNullWhen(true)] out string? isbn13)
{
isbn13 = null;
if (string.IsNullOrWhiteSpace(input))
return false;
ReadOnlySpan<char> span = input.AsSpan().Trim();
if (span.StartsWith("ISBN", StringComparison.OrdinalIgnoreCase))
span = span[4..].TrimStart(": ");
Span<char> digits = stackalloc char[13];
int count = 0;
foreach (char c in span)
{
if (Separators.Contains(c))
continue;
if (!char.IsAsciiDigit(c) || count == digits.Length)
return false;
digits[count++] = c;
}
if (count != digits.Length || !HasValidCheckDigit(digits))
return false;
isbn13 = new string(digits);
return true;
}
// ISBN-13: digits are weighted 1,3,1,3...; the total must be a multiple of 10.
private static bool HasValidCheckDigit(ReadOnlySpan<char> digits)
{
int sum = 0;
for (int i = 0; i < digits.Length; i++)
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
return sum % 10 == 0;
}
"ISBN 978-0-441-17271-9" -> 9780441172719
" isbn: 978 0441 172719 " -> 9780441172719
"9780441172719" -> 9780441172719
"ISBN 978-0-441-17271-8" -> invalid
"978-0-441-17271" -> invalid
"ISBN 978-0-441-17271-9X" -> invalid
"" -> invalid
null -> invalid
No step of the method creates an intermediate string: Trim, StartsWith and TrimStart work on a span, the digits are written to a 13-character buffer on the stack, and the only allocation is the final new string(digits). The prefix check uses OrdinalIgnoreCase because "isbn" is a fixed label, not a word in a language. We chose char.IsAsciiDigit over char.IsDigit: IsDigit also returns true for Unicode digits such as Arabic-Indic numerals, and then c - '0' produces a meaningless number. The fourth input with a wrong check digit, the fifth with missing digits and the sixth with a trailing letter are rejected. Putting the hyphens back in the right places is not this simple: the length of the group and publisher parts of an ISBN varies by country and publisher, and you need the ISBN agency's range table for that.
Newer APIs: SearchValues, CompositeFormat and Rune Overloads
SearchValues (.NET 8)
The Separators field above is of type SearchValues<char>, added in .NET 8. IndexOfAny(char[]) examines the character set again on every call; SearchValues.Create computes the best search strategy for the set (an ASCII bitmap, vectorized comparison) once, and when stored in a static readonly field it is reused by every search. An example that checks whether a query from the search box contains unwanted characters:
private static readonly SearchValues<char> UnsafeSearchChars = SearchValues.Create("<>\"'%;()&+");
foreach (string query in queries)
{
int index = query.AsSpan().IndexOfAny(UnsafeSearchChars);
Console.WriteLine($"{query,-18} IndexOfAny = {index,2}, ContainsAny = {query.AsSpan().ContainsAny(UnsafeSearchChars)}");
}
SearchValues<string> stopWords = SearchValues.Create(["the", "and", "of"], StringComparison.OrdinalIgnoreCase);
string title = "The Lord of the Rings";
Console.WriteLine($"First stop word at {title.AsSpan().IndexOfAny(stopWords)}");
dune IndexOfAny = -1, ContainsAny = False
dune<script> IndexOfAny = 4, ContainsAny = True
emma's IndexOfAny = 4, ContainsAny = True
middlemarch 1871 IndexOfAny = -1, ContainsAny = False
First stop word at 0
SearchValues<string> arrived in .NET 9 and searches for several words in a single pass; it accepts only Ordinal and OrdinalIgnoreCase. This example is an input check, not XSS protection. Encoding output is a separate job, covered in the Web Security series.
CompositeFormat (.NET 8)
The second .NET 8 API is CompositeFormat. string.Format("{0} x {1:N2}", ...) parses the format string from scratch on every call. If the same format is used thousands of times in a loop, you can parse it once and keep it:
private static readonly CompositeFormat InvoiceLine = CompositeFormat.Parse("{0,-12}{1,3} x {2,10:N2} = {3,10:N2}");
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");
foreach (var line in lines)
Console.WriteLine(string.Format(tr, InvoiceLine, line.Title, line.Quantity, line.Price, line.Quantity * line.Price));
Console.WriteLine($"Arguments expected: {InvoiceLine.MinimumArgumentCount}");
Dune 2 x 349,90 = 699,80
Middlemarch 1 x 1.249,50 = 1.249,50
Arguments expected: 4
FormatException: Input string was not in a correct format. Failure to parse near offset 8. Format item ends prematurely.
The last line comes from a CompositeFormat.Parse("{0} x {1") call: the format error is caught when you parse the string, not when you use it. Because it's a static readonly field, that happens when the application starts. MinimumArgumentCount tells you how many arguments the format expects, which is useful in tests. We look at the ,-12 and :N2 parts of the format string in detail in the next post.
Rune Overloads (.NET 11)
A char is a 16-bit UTF-16 code unit; emoji and some rare characters take two chars (a surrogate pair). In .NET 10, if you try to remove the emoji that labels a shelf with Trim, the compiler refuses:
string shelfLabel = "📚 Dune 📚";
Console.WriteLine(shelfLabel.Trim('📚'));
Program.cs(2,35): error CS1012: Too many characters in character literal
The type that represents a single Unicode character has been System.Text.Rune since .NET Core 3.0. .NET 11 added overloads taking a Rune to the string's search and edit methods: Contains, StartsWith, EndsWith, IndexOf, LastIndexOf, Replace, Split, Trim, TrimStart and TrimEnd. The Bookstore.Bcl.Net11 project in the repo needs the .NET 11 SDK:
Rune books = new(0x1F4DA);
Console.WriteLine($"Length: {shelfLabel.Length} chars, {shelfLabel.EnumerateRunes().Count()} runes");
Console.WriteLine($"Contains(Rune) : {shelfLabel.Contains(books)}");
Console.WriteLine($"IndexOf(Rune) : {shelfLabel.IndexOf(books)}");
Console.WriteLine($"LastIndexOf : {shelfLabel.LastIndexOf(books)}");
Console.WriteLine($"Trim(Rune) : [{shelfLabel.Trim(books).Trim()}]");
Console.WriteLine($"Split(Rune) : {string.Join(" | ", "Dune📚Emma📚Middlemarch".Split(books, StringSplitOptions.None))}");
Console.WriteLine($"Replace(Rune, Rune): {shelfLabel.Replace(books, new Rune('*'))}");
Length: 10 chars, 8 runes
Contains(Rune) : True
IndexOf(Rune) : 0
LastIndexOf : 8
Trim(Rune) : [Dune]
Split(Rune) : Dune | Emma | Middlemarch
Replace(Rune, Rune): * Dune *
Length says 10 because the two emoji hold four chars; LastIndexOf returns 8 for the same reason, since it is a char index. In .NET 10 you could do the same job by passing the emoji as a string (Contains("📚")); the new overloads state the intent in the type and stop you from passing anything other than a single character at compile time.
In Production
Culture-dependent defaults are hard to catch in code review; that's what analyzers are for. When I built the project with AnalysisLevel=latest-recommended, the sample file of this post produced these warnings (shortened):
dotnet build -p:AnalysisLevel=latest-recommended samples/Bookstore.Bcl.Samples
StringDemos.cs(30,27): warning CA1310: The behavior of 'string.IndexOf(string)' could vary based on the current user's locale settings. Replace this call in 'Bookstore.Bcl.Samples.Strings.StringDemos.Guess()' with a call to 'string.IndexOf(string, System.StringComparison)'.
StringDemos.cs(46,44): warning CA1310: The behavior of 'string.StartsWith(string)' could vary based on the current user's locale settings. Replace this call in 'Bookstore.Bcl.Samples.Strings.StringDemos.Search()' with a call to 'string.StartsWith(string, System.StringComparison)'.
StringDemos.cs(48,47): warning CA1309: Use ordinal string comparison
With the default settings none of these rules warns; a clean build doesn't mean the code is culture-independent. Making CA1307, CA1309, CA1310 (string comparison) and CA1304, CA1305, CA1311 (culture-dependent conversion) mandatory through .editorconfig or WarningsAsErrors is covered in the ".NET Analyzers" post of the Static Analysis series. The same build also reported CA1865 and CA1847: they suggest the char overload instead of a single-character string, because the char overload is both ordinal and faster.
The second point is the data source. Text from web forms, spreadsheets and old systems carries invisible characters: soft hyphens, zero-width spaces, null characters, non-breaking spaces. Clean them at the boundary where they enter the system, not at comparison time (as we did in the ISBN and slug examples), and use only ordinal comparison inside. That also keeps the same book from being stored twice in the database.
The third is measurement. The byte counts in this post show allocations, not speed. Before switching to SearchValues or string.Create, measure with BenchmarkDotNet that the code is really on the hot path; the BenchmarkDotNet post of the Performance and Load Testing series shows how.
Common Pitfalls
Calling IndexOf(string), StartsWith(string) or EndsWith(string) without a StringComparison. These overloads are culture-sensitive; on a Turkish server "I" and "i" don't match in a case-insensitive comparison, and ICU ignoring zero-weight characters produces false positives. For machine data always pass Ordinal.
Forgetting empty elements after Split. A trailing comma, a double comma and whitespace-only pieces end up in the array; parts[2] won't be the value you expect. For user input use RemoveEmptyEntries | TrimEntries together.
Growing a string with += in a loop. We saw it allocate 10 MB for a thousand elements; at ten thousand it grows quadratically. Use string.Join or StringBuilder.
Using ToLower() to build slugs or keys. It gives different results depending on culture; for every string used by machines choose ToLowerInvariant or an ordinal comparison.
Using char.IsDigit to check for digits. It returns true for every digit character in Unicode; if you expect ASCII digits, use char.IsAsciiDigit (.NET 7).
A Small Experiment
★☆☆ Run the strings-search example with the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 environment variable and note which lines change. Then add a pasted.StartsWith("Middlemarch", StringComparison.InvariantCulture) line and try both modes again.
★★☆ Pass the titles "Straße" and "Ærø" to Slug.FromTitle and look at the resulting slugs. Add a small mapping table that converts these letters to ASCII equivalents (ss, ae, o) and write an option that cuts the slug at the last hyphen.
★★★ Add ISBN-10 support to IsbnNormalizer: accept 10-digit input (the last digit may be X), validate the check digit and convert it to ISBN-13 with the 978 prefix. Remember that the check digit has to be recalculated during the conversion.
In this post we searched, split, joined and cleaned strings. In the next post, on standard and custom format strings, we'll see exactly what parts like {2,10:N2} in the invoice line do, why the same price is written 1.249,50 under tr-TR and 1,249.50 under en-US, and how to make our own Isbn type printable with format strings.