Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 4 – Strings and text

Strings: slicing, immutability and comparison

Slice C# strings with indexes and ranges, split typed text cleanly, see why every change makes a new string, and compare with an explicit StringComparison.

  • Beginner
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Slice a string with indexes, ranges, Substring and IndexOf without going past its end
  • Split and trim typed text with StringSplitOptions
  • Explain why string methods return new strings, and what += costs in a loop
  • Compare strings with an explicit StringComparison, ordinal for identifiers and culture-aware for text people read

Before you start

What you’ll learn · Before you start

What you will learn

  • Slice a string with indexes, ranges, Substring and IndexOf without going past its end
  • Split and trim typed text with StringSplitOptions
  • Explain why string methods return new strings, and what += costs in a loop
  • Compare strings with an explicit StringComparison, ordinal for identifiers and culture-aware for text people read

Before you start

On this page

The booking reference PNR-4519-KL is 11 chars long, and pnr[4..8] gives its number, 4519, because a range stops just before its second index. Most string bugs in C# come down to a detail of that size: a part cut one place too far, a result that was never stored, or two strings compared by a rule nobody chose.

You will cut strings with indexes, ranges and Split, see why a string never changes once it exists, and compare strings with a StringComparison that says exactly how: ordinal for names that programs use, culture-aware only for text that people read.

Indexes and ranges cut a string into parts

A C# string is a read-only sequence of char values, and pnr[0] is its first one, P. An index counts from 0, so the last char of an 11-char string is at index 10. An index from end: An index written with ^, such as ^1, that counts back from the end of a string, array or span: ^1 is the last element, ^2 the one before it, and ^0 the position just past the end., ^1, counts backwards: pnr[^1] is L. A range: Two indexes written a..b, such as s[4..8]: the part of a string, array or span from index a up to, but not including, index b. Either end may be left out, as in s[..3] or s[^2..]. such as pnr[4..8] takes the chars from index 4 up to, but not including, index 8, and either end may be left out. Substring(4, 4) gives the same 4519, but its second number is a length, not an end. This program cuts the reference every way:

slice.cs: a booking reference cut with indexes, ranges and Substring C# · slice/slice.cs
// A booking reference, cut into pieces with indexes and ranges.
string pnr = "PNR-4519-KL";

Console.WriteLine($"Length       {pnr.Length}");
Console.WriteLine($"pnr[0]       {pnr[0]}");
Console.WriteLine($"pnr[^1]      {pnr[^1]}");
Console.WriteLine($"pnr[..3]     {pnr[..3]}");
Console.WriteLine($"pnr[4..8]    {pnr[4..8]}");
Console.WriteLine($"pnr[^2..]    {pnr[^2..]}");
Console.WriteLine($"Substring(4, 4)  {pnr.Substring(4, 4)}");

// The number sits between the first and the last dash, wherever they are.
int first = pnr.IndexOf('-');
int last = pnr.LastIndexOf('-');
Console.WriteLine($"Dashes at {first} and {last}, so the number is pnr[{first + 1}..{last}] = {pnr[(first + 1)..last]}");

// Substring takes a start and a length, a range a start and an end.
try
{
    Console.WriteLine(pnr.Substring(4, 8));
}
catch (ArgumentOutOfRangeException e)
{
    Console.WriteLine($"Substring(4, 8)  {e.GetType().Name}: 4 + 8 is past the end");
}
try
{
    Console.WriteLine(pnr[11]);
}
catch (IndexOutOfRangeException e)
{
    Console.WriteLine($"pnr[11]      {e.GetType().Name}: the last index is 10");
}

Output

Length       11
pnr[0]       P
pnr[^1]      L
pnr[..3]     PNR
pnr[4..8]    4519
pnr[^2..]    KL
Substring(4, 4)  4519
Dashes at 3 and 8, so the number is pnr[4..8] = 4519
Substring(4, 8)  ArgumentOutOfRangeException: 4 + 8 is past the end
pnr[11]      IndexOutOfRangeException: the last index is 10

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run slice.cs

Read the ranges in the output against this table. Each one starts at its first index and stops before its second, which is why pnr[4..8] holds 8 − 4 = 4 chars:

Expression Result Why
pnr[0] P the first char is at index 0
pnr[^1] L ^1 is the last char, ^2 the one before it
pnr[..3] PNR from the start, stopping before index 3
pnr[4..8] 4519 indexes 4, 5, 6 and 7
pnr[^2..] KL from two before the end to the end
pnr.Substring(4, 4) 4519 start at 4, take 4 chars

IndexOf('-') and LastIndexOf('-') found the dashes at 3 and 8, so pnr[(first + 1)..last] cut out the number without hard-coding a position. That keeps working when a reference has a longer number. Both methods return -1 when the char is missing, so check for -1 before you use the result in a range.

Giving Substring an end instead of a length

pnr.Substring(4, 8) does not mean “from 4 to 8”: it asks for 8 chars starting at 4, which runs 1 char past the end of the 11-char string and throws ArgumentOutOfRangeException, as the run shows. Write the range pnr[4..8] when you think in positions, and Substring(start, length) only when you know the length.

Checkpoint

Check: Indexes, ranges and Substring

  1. Question 1 of 2 string code = "INV-2041-07"; which expression gives "2041"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: code[4..8]

    Indexes start at 0, so the "2" after "INV-" is at index 4. The range 4..8 takes indexes 4, 5, 6 and 7, which is 8 − 4 = 4 chars, exactly like pnr[4..8] gave 4519 in slice.cs.

  2. Question 2 of 2 string word = "Kolkata"; what does word.Substring(3, 5) do?

    Choose one answer.

    A hint

    Count the chars from index 3 to the end.

    Show the answer: Question 2 of 2

    Answer: It throws ArgumentOutOfRangeException

    Substring(start, length) needs start + length to be at most the string's Length. "Kolkata" has 7 chars, and 3 + 5 = 8, so the call throws. word[3..] would give "kata", and word[3..5] would give "ka".

Split and Trim turn typed text into clean parts

People type names with spaces around them and press the comma key twice. Split(',') cuts the text at every comma and keeps everything between the commas, spaces and empty parts included. Two options of StringSplitOptions clean that up: TrimEntries takes the white space off each part, and RemoveEmptyEntries drops the empty ones. The TrimEntries option needs .NET 5 or later, and used together the two options also drop a part that held only spaces:

split.cs: names typed into a form, split three ways C# · split/split.cs
// Names typed into a form, separated by commas, with stray spaces and an empty entry.
string typed = " Asha , Ravi,,  Meera ";

string[] plain = typed.Split(',');
Console.WriteLine($"Split(',')         {plain.Length} parts: {Show(plain)}");

string[] trimmed = typed.Split(',', StringSplitOptions.TrimEntries);
Console.WriteLine($"+ TrimEntries      {trimmed.Length} parts: {Show(trimmed)}");

string[] clean = typed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Console.WriteLine($"+ RemoveEmpty...   {clean.Length} parts: {Show(clean)}");

// Trim alone, and the test for text that holds nothing but spaces.
Console.WriteLine($"Trim()             [{typed.Trim()}]");
Console.WriteLine($"IsNullOrWhiteSpace(\"   \")  {string.IsNullOrWhiteSpace("   ")}");
Console.WriteLine($"IsNullOrEmpty(\"   \")       {string.IsNullOrEmpty("   ")}");

static string Show(string[] parts) => string.Join(" ", parts.Select(p => $"[{p}]"));

Output

Split(',')         4 parts: [ Asha ] [ Ravi] [] [  Meera ]
+ TrimEntries      4 parts: [Asha] [Ravi] [] [Meera]
+ RemoveEmpty...   3 parts: [Asha] [Ravi] [Meera]
Trim()             [Asha , Ravi,,  Meera]
IsNullOrWhiteSpace("   ")  True
IsNullOrEmpty("   ")       False

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run split.cs

Step through what happened to the parts. The count is the thing to watch: 4 parts, still 4 after trimming, and 3 once the empty part has gone:

Interactive · stage by stage What Split does with typed text Step through one split of the names typed into a form, then each option in turn, and watch the number of parts.
What Split does with typed text, step 5 of 5. Showing The typed text, Cut at each comma (4 parts, one of them empty), TrimEntries (Asha, Ravi, empty, Meera), RemoveEmptyEntries (the empty part goes), The array ([Asha] [Ravi] [Meera]). In focus: The array.The typed textthree names and two commasCut at each comma4 parts, one of them emptyTrimEntriesAsha, Ravi, empty, MeeraRemoveEmptyEntriesthe empty part goesThe array[Asha] [Ravi] [Meera]

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The text. Split starts from the text exactly as it was typed: three names with spaces around them, and two commas in a row where someone pressed the key twice.
  2. Cut at commas. With no options, Split cuts at every comma and keeps everything between them: 4 parts, with the spaces still on them and an empty part between the two commas.
  3. Trim each part. TrimEntries takes the white space off both ends of every part. The names are clean now, but there are still 4 parts, because the empty part is still there.
  4. Drop empty parts. RemoveEmptyEntries drops the parts that are empty. Used with TrimEntries it also drops a part that held only spaces, because trimming leaves it empty.
  5. Three names. The array holds 3 names, Asha, Ravi and Meera, which is what the person meant to type. split.cs printed the same counts: 4, 4, then 3.

Trim() alone works on the whole text and takes white space off its two ends only, which is why the commas and the inner spaces stayed. To reject a field that holds nothing but spaces, use string.IsNullOrWhiteSpace, which said True for " ", rather than IsNullOrEmpty, which said False because three spaces are not empty.

Checkpoint

Check: Split, Trim and empty parts

  1. Question 1 of 2 How many parts does "red,,blue,".Split(',') return?

    Type a number.

    A hint

    An empty part sits between two commas, and after a comma at the end.

    Show the answer: Question 1 of 2

    Answer: 4 parts

    Split with no options keeps every part between commas: "red", an empty part between the two commas, "blue", and an empty part after the last comma, which makes 4. RemoveEmptyEntries would leave 2.

  2. Question 2 of 2 A form field holds three spaces. Which test rejects it as blank?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: string.IsNullOrWhiteSpace(field)

    Spaces count as chars, so only a test that looks at white space catches them. IsNullOrWhiteSpace treats null, the empty string and white space alike; IsNullOrEmpty does not look at white space.

A string never changes; its methods return new strings

name.Trim(); on a line of its own looks as if it trims name, yet the program printed name with its spaces still there. Strings in C# are immutable: Unable to change after it is made. A C# string is immutable: Trim, Replace, ToUpperInvariant and += never change a string, they return a new one.: once a string exists, its chars never change. Every method that seems to change one, such as Trim, Replace or ToUpperInvariant, makes a new string and returns it, and Microsoft’s guide to strings says the same of the += operator. A variable holds a reference to a string, so it keeps the old one until you assign the new one to it:

immutable.cs: strings that are never changed, and what += allocates C# · immutable/immutable.cs
// A string never changes: every method that seems to change one returns a new string.
string name = "  Asha Rao  ";

name.Trim();                              // the trimmed copy is made, then thrown away
Console.WriteLine($"After name.Trim();         [{name}]");

string trimmed = name.Trim();             // keep the new string in a variable
Console.WriteLine($"trimmed = name.Trim()      [{trimmed}], name is still [{name}]");

string alias = trimmed;                   // two variables, one string
trimmed = trimmed.ToUpperInvariant();     // trimmed now refers to a new string
Console.WriteLine($"After ToUpperInvariant     trimmed [{trimmed}], alias [{alias}]");
Console.WriteLine($"Same object?               {ReferenceEquals(trimmed, alias)}");

// Building text with += makes a new, longer string on every pass, and copies
// everything built so far into it.
foreach (int n in new[] { 1000, 2000, 4000 })
{
    long before = GC.GetAllocatedBytesForCurrentThread();
    string line = "";
    for (int i = 0; i < n; i++)
    {
        line += "x";
    }
    long bytes = GC.GetAllocatedBytesForCurrentThread() - before;
    Console.WriteLine($"{n,5} passes of += \"x\": a {line.Length}-char result, {bytes / 1000} KB allocated");
}

Output

After name.Trim();         [  Asha Rao  ]
trimmed = name.Trim()      [Asha Rao], name is still [  Asha Rao  ]
After ToUpperInvariant     trimmed [ASHA RAO], alias [Asha Rao]
Same object?               False
 1000 passes of += "x": a 1000-char result, 1025 KB allocated
 2000 passes of += "x": a 2000-char result, 4051 KB allocated
 4000 passes of += "x": a 4000-char result, 16103 KB allocated

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run immutable.cs

Step through the first lines. The variables are on the left and the string objects on the right; watch which arrow moves and which string is left with no arrow at all:

Interactive · step by step Variables and the strings they refer to Step through the first lines of immutable.cs. Variables stand on the left, the strings on the heap on the right.
Variables and the strings they refer to, step 6 of 6. Showing name, " Asha Rao ", trimmed, "Asha Rao", alias, "ASHA RAO". In focus: " Asha Rao ", "Asha Rao", "ASHA RAO".name" Asha Rao "12 charstrimmed"Asha Rao"8 charsalias"ASHA RAO"8 chars

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One string. name refers to a string of 12 chars, with two spaces on each side. The variable holds a reference; the chars live in the string object.
  2. name.Trim();. Trim makes a new string of 8 chars and returns it. The statement does not store it anywhere, so nothing refers to it, and the garbage collector will reclaim it. name is unchanged.
  3. trimmed = name.Trim(). The same call again, this time with the result stored. trimmed refers to a new string "Asha Rao", and name still refers to the original with its spaces.
  4. alias = trimmed. Assigning a string copies the reference, not the chars: alias and trimmed now refer to the same string object.
  5. ToUpperInvariant. trimmed = trimmed.ToUpperInvariant() makes a third string, "ASHA RAO", and moves trimmed to it. alias still refers to "Asha Rao", which is why the program printed both.
  6. Nothing changed in place. Three strings were made and none was changed after it was made. ReferenceEquals(trimmed, alias) is False because the two variables refer to different objects.

Because nothing changes in place, two variables can safely share one string: alias kept "Asha Rao" when trimmed moved on to "ASHA RAO". The price is paid when a loop builds text piece by piece. Each += makes a new string and copies everything built so far into it, so the copying grows with the square of the length. The run counted it with GC.GetAllocatedBytesForCurrentThread: 1,025 KB for a 1,000-char result, 4,051 KB for 2,000 chars and 16,103 KB for 4,000. Twice the text cost four times the memory, and a StringBuilder avoids that, as a later lesson of this module measures.

Calling a string method and dropping its result

name.Trim(); and name.Replace("Rao", "Rau"); compile and do nothing useful, because the new string is thrown away. Assign it: name = name.Trim();. The same holds for ToUpperInvariant, Insert, Remove and every other string method.

Both habits, keeping every result and building long text in a builder, come from the same fact:

Key idea

A string never changes after it is made: keep the result of every string method you call, and build long text with a StringBuilder instead of += in a loop.

Checkpoint

Check: Strings never change

  1. Question 1 of 2 What does this print?

    Read the code, then choose one answer.

    string city = "pune";
    city.ToUpperInvariant();
    Console.WriteLine(city);
    Show the answer: Question 1 of 2

    Answer: pune

    Strings are immutable. The call makes a new string, but nothing stores it, exactly like name.Trim(); in immutable.cs. Assign the result to keep it.

  2. Question 2 of 2 The run allocated 4,051 KB for 2,000 passes of += and 16,103 KB for 4,000. About how many KB would 8,000 passes allocate?

    Type a number.

    A hint

    Each pass copies the whole string built so far, so look at what doubling did between the two runs.

    Show the answer: Question 2 of 2

    Answer: 64400 KB (anything from 58400 to 70400 counts)

    Pass k copies about k chars, so n passes copy about n²/2 chars: twice the passes, four times the bytes. The run showed it, 4,051 KB to 16,103 KB, so 8,000 passes need about 4 × 16,103, roughly 64,400 KB, over 60 MB for a string of 8,000 chars.

Ordinal comparison checks every char; StringComparison says how

A user types Admin and the stored name is admin. For strings, == compares the chars one by one, so it said False, which is right for a case-sensitive code and wrong for a user name. string.Equals(typed, stored, StringComparison.OrdinalIgnoreCase) says True. An ordinal comparison: A comparison of strings by the numeric value of each char (StringComparison.Ordinal), the same on every computer. OrdinalIgnoreCase does the same after folding upper and lower case together. compares the numeric value of each char, so it gives the same answer on every computer, and OrdinalIgnoreCase does the same after folding upper and lower case together:

ordinal.cs: equality, order and searches with an explicit comparison C# · ordinal/ordinal.cs
// Ordinal comparisons look at the numeric value of each char, so they give the
// same answer on every computer, whatever language it is set to.
string stored = "admin";
string typed = "Admin";

Console.WriteLine($"typed == stored                       {typed == stored}");
Console.WriteLine($"Equals(typed, stored, Ordinal)        {string.Equals(typed, stored, StringComparison.Ordinal)}");
Console.WriteLine($"Equals(typed, stored, OrdinalIgnoreCase)  {string.Equals(typed, stored, StringComparison.OrdinalIgnoreCase)}");

// Compare returns a negative number, zero or a positive number. Ordinal order is
// the order of the char values: 'B' is 66 and 'a' is 97, so "B" comes first.
Console.WriteLine($"Compare(\"a\", \"B\", Ordinal)            {string.Compare("a", "B", StringComparison.Ordinal)}");
Console.WriteLine($"Compare(\"a\", \"B\", OrdinalIgnoreCase)  {string.Compare("a", "B", StringComparison.OrdinalIgnoreCase)}");

// Searching with an explicit comparison.
string path = "Reports/Q3/Sales.CSV";
Console.WriteLine($"StartsWith(\"reports/\", OrdinalIgnoreCase)  {path.StartsWith("reports/", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"EndsWith(\".csv\", Ordinal)                  {path.EndsWith(".csv", StringComparison.Ordinal)}");
Console.WriteLine($"EndsWith(\".csv\", OrdinalIgnoreCase)        {path.EndsWith(".csv", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"IndexOf(\"q3\", OrdinalIgnoreCase)           {path.IndexOf("q3", StringComparison.OrdinalIgnoreCase)}");

// A set of user names that ignores case, the same way on every computer.
var users = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "admin", "asha" };
Console.WriteLine($"users.Add(\"ASHA\")                          {users.Add("ASHA")}");
Console.WriteLine($"users.Contains(\"Admin\")                    {users.Contains("Admin")}");

Output

typed == stored                       False
Equals(typed, stored, Ordinal)        False
Equals(typed, stored, OrdinalIgnoreCase)  True
Compare("a", "B", Ordinal)            31
Compare("a", "B", OrdinalIgnoreCase)  -1
StartsWith("reports/", OrdinalIgnoreCase)  True
EndsWith(".csv", Ordinal)                  False
EndsWith(".csv", OrdinalIgnoreCase)        True
IndexOf("q3", OrdinalIgnoreCase)           8
users.Add("ASHA")                          False
users.Contains("Admin")                    True

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run ordinal.cs

Compare("a", "B", Ordinal) returned 31, the gap between a (97) and B (66), so in ordinal order every capital letter comes before every small one. Searching takes a comparison too: EndsWith(".csv", Ordinal) missed .CSV and the OrdinalIgnoreCase version found it. A HashSet<string> built with StringComparer.OrdinalIgnoreCase refused ASHA as a second user because asha was already in it.

The trap is that the methods do not agree on what they do without a comparison, as Microsoft’s best-practices guide lists:

Call without a StringComparison Compares by
==, string.Equals(a, b), Contains(string) ordinal
IndexOf(char), StartsWith(char) ordinal
StartsWith(string), EndsWith(string), IndexOf(string) the current culture
string.Compare(a, b), CompareTo, List<string>.Sort() the current culture

Let the build find the defaults

Code analysis rules CA1307 and CA1310 report calls that compare strings without a StringComparison, and CA1309 reports comparisons that are not ordinal. None of them is on by default: turn them on in the project file and pass a StringComparison to every call they report.

Checkpoint

Check: Ordinal comparison and its defaults

  1. Question 1 of 2 string a = "Admin", b = "admin"; which of these is true?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: string.Equals(a, b, StringComparison.OrdinalIgnoreCase)

    == and the Ordinal comparison look at the exact char values. Only an IgnoreCase comparison treats Admin and admin as the same name.

  2. Question 2 of 2 Which call compares by the current culture, because it is given no StringComparison?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: text.IndexOf("ERROR")

    The defaults differ by method and even by overload: IndexOf, StartsWith and EndsWith with a string argument use the current culture, while ==, Equals and Contains are ordinal. Passing a StringComparison removes the doubt.

Culture-aware comparison is for text that people read

Sort eight names by ordinal order and you get Apple, Banana, Zoo, apple, cherry, zebra, Éclair, éclair: capitals first, accented letters last, because that is the order of the char values. A reader expects a dictionary order. A linguistic comparison: A culture-aware comparison of strings that follows the sorting and casing rules of a culture (StringComparison.CurrentCulture or InvariantCulture): right for text people read, risky for identifiers. follows the rules of a culture: The conventions of a language and region, such as en-IN or de-DE, that .NET uses to format and parse numbers, dates and money. CultureInfo.CurrentCulture follows the settings of the computer. More in “Console input, output, arguments and exit codes” instead, and those rules can surprise code that was never meant for readers. Since .NET 5, those rules come from ICU, the International Components for Unicode, on Linux and macOS and on recent versions of Windows, so the next program is a small project with that culture data switched on, built and run:

cultures/Program.cs C# · cultures/Program.cs
using System.Globalization;

// Every line names the culture it uses, so the output is the same on any computer
// that has the culture data (macOS and Linux get it from ICU, Windows 10 and later too).
CultureInfo india = CultureInfo.GetCultureInfo("en-IN");
CultureInfo turkey = CultureInfo.GetCultureInfo("tr-TR");

string[] names = ["zebra", "Éclair", "apple", "Zoo", "éclair", "Banana", "Apple", "cherry"];

Console.WriteLine("The same eight names, sorted three ways");
Sorted("Ordinal", StringComparer.Ordinal);
Sorted("en-IN", StringComparer.Create(india, ignoreCase: false));
Sorted("Invariant", StringComparer.InvariantCulture);

// Sort() without a comparer uses the current culture.
CultureInfo.CurrentCulture = india;
List<string> list = [.. names];
list.Sort();
Console.WriteLine($"  {"Sort()",-10}{string.Join(", ", list)}");

Console.WriteLine();
Console.WriteLine("Upper and lower case under tr-TR");
Console.WriteLine($"  \"file\".ToUpper(tr-TR)      {"file".ToUpper(turkey)}");
Console.WriteLine($"  \"file\".ToUpperInvariant()  {"file".ToUpperInvariant()}");
Console.WriteLine($"  \"TITLE\".ToLower(tr-TR)     {"TITLE".ToLower(turkey)}");
CultureInfo.CurrentCulture = turkey;
Console.WriteLine($"  \"file\".ToUpper() == \"FILE\"                     {"file".ToUpper() == "FILE"}");
Console.WriteLine($"  Equals(\"file\", \"FILE\", CurrentCultureIgnoreCase) {string.Equals("file", "FILE", StringComparison.CurrentCultureIgnoreCase)}");
Console.WriteLine($"  Equals(\"file\", \"FILE\", OrdinalIgnoreCase)        {string.Equals("file", "FILE", StringComparison.OrdinalIgnoreCase)}");
CultureInfo.CurrentCulture = india;

Console.WriteLine();
Console.WriteLine("Text that looks the same, compared both ways");
string hidden = "\u200Bfile.txt";   // a zero-width space, then the name
Console.WriteLine($"  \"\\u200Bfile.txt\" (Length {hidden.Length}) StartsWith(\"file\"): culture {hidden.StartsWith("file", StringComparison.CurrentCulture)}, ordinal {hidden.StartsWith("file", StringComparison.Ordinal)}");
string oneChar = "\u00C5";          // Å as one char
string twoChars = "A\u030A";        // A, then a combining ring above
Console.WriteLine($"  Å as {oneChar.Length} char and as {twoChars.Length}: equal under en-IN {string.Equals(oneChar, twoChars, StringComparison.CurrentCulture)}, invariant {string.Equals(oneChar, twoChars, StringComparison.InvariantCulture)}, ordinal {string.Equals(oneChar, twoChars, StringComparison.Ordinal)}");

void Sorted(string label, StringComparer comparer)
{
    string[] copy = [.. names];
    Array.Sort(copy, comparer);
    Console.WriteLine($"  {label,-10}{string.Join(", ", copy)}");
}
Building and running cultures Python · cultures_session.py
from session import run

# cultures compares and changes the case of text under cultures it names itself; it is built once and run once.
run("""
cd cultures
dotnet build -v q
dotnet bin/Debug/net10.0/App.dll
""")

Output

$ cd cultures
$ dotnet build -v q

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed …
$ dotnet bin/Debug/net10.0/App.dll
The same eight names, sorted three ways
  Ordinal   Apple, Banana, Zoo, apple, cherry, zebra, Éclair, éclair
  en-IN     apple, Apple, Banana, cherry, éclair, Éclair, zebra, Zoo
  Invariant apple, Apple, Banana, cherry, éclair, Éclair, zebra, Zoo
  Sort()    apple, Apple, Banana, cherry, éclair, Éclair, zebra, Zoo

Upper and lower case under tr-TR
  "file".ToUpper(tr-TR)      FİLE
  "file".ToUpperInvariant()  FILE
  "TITLE".ToLower(tr-TR)     tıtle
  "file".ToUpper() == "FILE"                     False
  Equals("file", "FILE", CurrentCultureIgnoreCase) False
  Equals("file", "FILE", OrdinalIgnoreCase)        True

Text that looks the same, compared both ways
  "\u200Bfile.txt" (Length 9) StartsWith("file"): culture True, ordinal False
  Å as 1 char and as 2: equal under en-IN True, invariant True, ordinal False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cultures_session.py

en-IN put the names in dictionary order, and so did Sort() with no comparer, because it compares in the current culture. The middle block is the Turkish I: Turkish pairs i with a capital İ that has a dot, so under tr-TR "file".ToUpper() is FİLE, and comparing file with FILE while ignoring case says False. Code that upper-cases a command or a user name before comparing it breaks for every user whose computer is set to Turkish. The last block shows what linguistic rules ignore: a zero-width space before a file name, and the difference between Å written as one char or as A plus a combining ring.

Answer two questions about the strings you compare, and the table rules out the comparisons that would get them wrong:

Interactive · answer and compare Which comparison fits your strings? Answer two questions about the strings you compare. The table marks the comparisons that do not fit, and says why.
Is the result shown to people, such as a sorted list of names on a screen?
Should two strings that differ only in upper and lower case count as the same?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to compare strings against three things the lesson's runs showed
Comparison Same answer on every computer Order a reader expects Unlike text stays unlike
Ordinal Compares by: the numeric value of each char Good char values never change: "B" (66) comes before "a" (97) everywhere Poor sorted Apple, Banana, Zoo before apple, and both Éclairs last Good Å as 1 char and as 2 chars were not equal
OrdinalIgnoreCase Compares by: char values, with case folded the invariant way Good "file" equalled "FILE" even with the culture set to tr-TR Poor still the order of char values, so the accented names sort after zebra Good only case is folded: a zero-width space still makes the text differ
CurrentCulture Compares by: the rules of the culture the program runs under Poor changes with the computer: under tr-TR, "file" and "FILE" were not equal Good en-IN sorted apple, Apple, Banana, cherry … zebra, Zoo Poor skipped a zero-width space: StartsWith("file") returned True
InvariantCulture Compares by: linguistic rules tied to no country Mixed one culture everywhere, but its rules come from culture data that can change Mixed a dictionary order close to English, not the order of every language Poor treated Å as 1 char and as 2 chars as equal
How the answers decide
  • “Is the result shown to people, such as a sorted list of names on a screen?” No, a program uses it: a key, a code, a path, a check: rules out CurrentCulture and InvariantCulture. Keys, codes, paths and security checks need one answer on every computer and exact chars, which only the ordinal comparisons give.
  • “Is the result shown to people, such as a sorted list of names on a screen?” Yes, people read it: rules out Ordinal and OrdinalIgnoreCase. Char-value order put Zoo before apple and the accented names after zebra, which is not the order a reader looks for.
  • “Is the result shown to people, such as a sorted list of names on a screen?” Yes, people read it: adds a caveat to InvariantCulture. Use it only for a fixed order that must look the same in every country; for one reader, compare in that reader's culture.
  • “Is the result shown to people, such as a sorted list of names on a screen?” No, a program uses it: a key, a code, a path, a check and “Should two strings that differ only in upper and lower case count as the same?” Yes, Admin and admin are the same: rules out Ordinal. Ordinal tells Admin from admin; OrdinalIgnoreCase found "Admin" in a set that held "admin".
  • “Is the result shown to people, such as a sorted list of names on a screen?” No, a program uses it: a key, a code, a path, a check and “Should two strings that differ only in upper and lower case count as the same?” No, case matters: rules out OrdinalIgnoreCase. Folding case would treat two different codes as one; Ordinal keeps them apart.

Unicode Character Inspector Paste a name copied from a web page to see whether a zero-width space or another invisible char hides in it.

Upper-casing both sides to compare them

if (input.ToUpper() == "FILE") fails under tr-TR, and makes two new strings on every call. Write string.Equals(input, "FILE", StringComparison.OrdinalIgnoreCase), which folds case the same way on every computer and allocates nothing.

Checkpoint

Check: Culture-aware comparison

  1. Question 1 of 2 A program sets CultureInfo.CurrentCulture to tr-TR. What does "file".ToUpper() == "FILE" give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: False, because the i becomes İ, a capital I with a dot

    Upper- and lower-casing follow the culture, and Turkish has dotted and dotless i. Comparing with OrdinalIgnoreCase gave True under tr-TR, because it folds case the same way everywhere.

  2. Question 2 of 2 A shop shows customers a sorted list of product names in an app used in India. Which comparer should the sort use?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: StringComparer.Create(CultureInfo.GetCultureInfo("en-IN"), ignoreCase: false), or the reader's culture

    A sort that people read is a linguistic job, so name the culture of the readers. Keys, codes and file names that only programs use are the opposite case: compare those ordinally.

Practise: normalise user names

usernames.cs checks a method that you write. Each check calls Normalise with a user name as someone might type it, with spaces around it, several spaces or a tab inside, a non-breaking space or capital letters, and compares the result with the key the name should be stored under. The method you start from only throws, so all 8 checks fail:

usernames.cs: eight checks of the Normalise method C# · usernames/usernames.cs
// Practice: write Normalise so that every check below passes, then run this file again.
// A user name is compared after three steps: the white space around it goes, every run of
// white space inside it becomes one space, and the result is lower-cased with ToLowerInvariant.
(string Typed, string Expected)[] checks =
[
    ("asha", "asha"),
    ("  Asha  ", "asha"),
    ("ASHA RAO", "asha rao"),
    ("Asha    Rao", "asha rao"),
    ("asha\trao", "asha rao"),
    ("asha\u00A0rao", "asha rao"),
    ("Ravi_K", "ravi_k"),
    ("   ", ""),
];

int passed = 0;
foreach (var (typed, expected) in checks)
{
    string verdict;
    try
    {
        string got = Normalise(typed);
        verdict = got == expected ? "ok" : $"FAIL, returned \"{got}\"";
    }
    catch (Exception e)
    {
        verdict = $"FAIL, {e.GetType().Name}";
    }
    if (verdict == "ok")
    {
        passed++;
    }
    Console.WriteLine($"Normalise(\"{Visible(typed)}\"): {verdict}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;

static string Normalise(string name)
{
    throw new NotImplementedException("Trim, collapse the white space inside, then ToLowerInvariant.");
}

// Shows a tab as \t and a non-breaking space as \u00A0, so each check can be read.
static string Visible(string s) => s.Replace("\t", "\\t").Replace("\u00A0", "\\u00A0");

Output (exit status 1)

Normalise("asha"): FAIL, NotImplementedException
Normalise("  Asha  "): FAIL, NotImplementedException
Normalise("ASHA RAO"): FAIL, NotImplementedException
Normalise("Asha    Rao"): FAIL, NotImplementedException
Normalise("asha\trao"): FAIL, NotImplementedException
Normalise("asha\u00A0rao"): FAIL, NotImplementedException
Normalise("Ravi_K"): FAIL, NotImplementedException
Normalise("   "): FAIL, NotImplementedException
0 of 8 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run usernames.cs

Write Normalise

Copy usernames.cs to your computer and replace the body of Normalise. Split the name with name.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries), which splits at every kind of white space, join the parts with string.Join(' ', parts), and lower-case the result with ToLowerInvariant(). Run the file with dotnet run usernames.cs until it prints 8 of 8 checks pass.

Then check yourself on the booking reference, the split names, the shared strings and the Turkish I: the session opens with whatever you have got wrong most often and explains every choice.

Practice that adapts to you

Practise this lesson

11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 11 For any non-empty string s, which index gives its last char?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: s[^1]

    Indexes run from 0 to Length − 1, and ^1 is shorthand for Length − 1. pnr[11] threw in slice.cs because an 11-char string's last index is 10.

  2. Question 2 of 11 How many chars does s[3..^2] hold when s.Length is 12?

    Type a number.

    A hint

    ^2 is the same index as s.Length − 2.

    Show the answer: Question 2 of 11

    Answer: 7 chars

    ^2 is index 12 − 2 = 10, so the range runs from 3 up to, but not including, 10: 10 − 3 = 7 chars.

  3. Question 3 of 11 A reference looks like "PNR-4519-KL" but the middle part may be longer, as in "PNR-45190-KL". Which expression always gives the middle part?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: pnr[(pnr.IndexOf('-') + 1)..pnr.LastIndexOf('-')]

    Positions found with IndexOf and LastIndexOf follow the text. Check that IndexOf did not return -1 before using it, because a missing dash would make the range start at 0.

  4. Question 4 of 11 What does "a, b ,,c".Split(',', StringSplitOptions.TrimEntries) return?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: 4 parts: "a", "b", "", "c"

    The two options do separate jobs: TrimEntries trims, RemoveEmptyEntries drops empty parts. Pass both, joined with |, to get only the clean, non-empty names.

  5. Question 5 of 11 A CSV line "Asha, ,Ravi" has a part of one space where a name is missing. Which call returns exactly the 2 names?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: line.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)

    The StringSplitOptions reference says that with both options, parts of white space only are removed too, which is what the last step of the split scene showed.

  6. Question 6 of 11 What does this print?

    Read the code, then choose one answer.

    string a = "tea";
    string b = a;
    a += " and toast";
    Console.WriteLine(b);
    Show the answer: Question 6 of 11

    Answer: tea

    Assigning a string copies the reference. a += … creates a new string and points a at it, so b keeps the old one. Nothing about the old string changed.

  7. Question 7 of 11 A report is built with report += line for 50,000 lines and takes far longer than for 5,000. Why?

    Choose one answer.

    Show the answer: Question 7 of 11

    Answer: Each += copies the whole report so far, so the work grows with the square of the number of lines

    Immutability means += cannot extend the existing string, so pass k copies about k lines. A StringBuilder appends into spare room instead, which the StringBuilder lesson of this module measures.

  8. Question 8 of 11 Sorted with StringComparer.Ordinal, in which order do "beta", "Alpha" and "alpha" come?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: Alpha, alpha, beta

    Ordinal compares the numeric value of each char, so case decides the order before the letters do. That is what put Apple, Banana and Zoo before apple in the cultures run.

  9. Question 9 of 11 User names must be unique whatever their case, on servers set to any language. How should the set of names be built?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: new HashSet<string>(StringComparer.OrdinalIgnoreCase)

    Identifiers need one answer everywhere. OrdinalIgnoreCase gives it, without allocating an upper-cased copy of every name.

  10. Question 10 of 11 Under en-IN, "\u200Bfile.txt".StartsWith("file", StringComparison.CurrentCulture) returned True. Why?

    Choose one answer.

    Show the answer: Question 10 of 11

    Answer: Linguistic rules ignore the zero-width space at the start

    Culture-aware comparison is built for readers, who never see a zero-width space. A program that checks file names or commands needs Ordinal, which returned False.

  11. Question 11 of 11 A filter blocks paths that start with "FILE:", case-insensitively. Which test holds on every computer?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: path.StartsWith("FILE:", StringComparison.OrdinalIgnoreCase)

    Microsoft's guide warns that culture comparisons in filters and access checks can give answers that change by locale. Checks on identifiers belong with the ordinal comparisons.

Interview questions

Fresher · What does == compare when both sides are strings?

The chars. C# defines == for two string operands as an ordinal comparison of their contents, the same as string.Equals(a, b): the strings are equal when they have the same length and the same char at every index. It does not compare references, so two different string objects with the same chars are equal, and it is case-sensitive, so "Admin" == "admin" is false. It gives the same answer on every computer, whatever culture is set. When case should not matter, call string.Equals(a, b, StringComparison.OrdinalIgnoreCase); when people will read a sorted list, compare in their culture. A string held in a variable of type object is compared by reference with ==, so keep both sides typed as string.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A sign-in check compares user names with typed.ToUpper() == stored.ToUpper(). Users whose phones are set to Turkish report that names containing an i are refused. Explain the cause, how you would reproduce it in a test, and the fix.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Two services sort the same list of product codes with List<string>.Sort() and return them in different orders: one runs on a server set to en-IN, the other in a container image built with InvariantGlobalization. Why, which comparison should each sort use, and how do you make the output stable?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An index counts from 0, ^1 is the last char, and a range a..b stops before b, while Substring(start, length) takes a length.
  • Split keeps spaces and empty parts unless you pass TrimEntries and RemoveEmptyEntries, and IsNullOrWhiteSpace catches a field of nothing but spaces.
  • A string never changes: every string method returns a new string, so keep the result, and avoid += in a loop, whose cost grows with the square of the length.
  • == compares strings ordinally, but several other methods use the current culture by default, so pass a StringComparison to every call.
  • Use Ordinal or OrdinalIgnoreCase for identifiers, keys, paths and checks, and a culture only for text that people read, such as a sorted list of names.

Terms in this lesson

immutable
Unable to change after it is made. A C# string is immutable: Trim, Replace, ToUpperInvariant and += never change a string, they return a new one.
index from end
An index written with ^, such as ^1, that counts back from the end of a string, array or span: ^1 is the last element, ^2 the one before it, and ^0 the position just past the end.
linguistic comparison
A culture-aware comparison of strings that follows the sorting and casing rules of a culture (StringComparison.CurrentCulture or InvariantCulture): right for text people read, risky for identifiers.
ordinal comparison
A comparison of strings by the numeric value of each char (StringComparison.Ordinal), the same on every computer. OrdinalIgnoreCase does the same after folding upper and lower case together.
range
Two indexes written a..b, such as s[4..8]: the part of a string, array or span from index a up to, but not including, index b. Either end may be left out, as in s[..3] or s[^2..].

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.