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.
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:
// 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
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:
// 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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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
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:
// 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- alias = trimmed. Assigning a string copies the reference, not the chars: alias and trimmed now refer to the same string object.
- 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.
- 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
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 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
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:
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)}");
} 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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.
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
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:
// 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.
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.
-
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.
-
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?
Key takeaways
- An index counts from 0,
^1is the last char, and a rangea..bstops beforeb, whileSubstring(start, length)takes a length. Splitkeeps spaces and empty parts unless you passTrimEntriesandRemoveEmptyEntries, andIsNullOrWhiteSpacecatches 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 aStringComparisonto every call.- Use
OrdinalorOrdinalIgnoreCasefor 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
- Strings (C# programming guide) (Microsoft Learn)
- Explore indexes and ranges (Microsoft Learn)
- String.Substring method (Microsoft Learn)
- StringSplitOptions enum (Microsoft Learn)
- String.IsNullOrWhiteSpace method (Microsoft Learn)
- Best practices for comparing strings in .NET (Microsoft Learn)
- CA1307: Specify StringComparison for clarity (Microsoft Learn)
- CA1309: Use ordinal StringComparison (Microsoft Learn)
- CA1310: Specify StringComparison for correctness (Microsoft Learn)
- .NET globalization and ICU (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress