C# · lesson 8 of 26
Strings in C#
C# strings by example: immutability, interpolation and format specifiers, verbatim and raw literals, StringBuilder, Span<char> parsing and Regex.
By SkillByExample · Updated
String Basics
A C# string is immutable: methods like ToUpper or Replace return a new string and leave the original alone.
string name = "Alice";
Console.WriteLine(name.Length);
Console.WriteLine($"{name[0]} {name[^1]}");
Console.WriteLine(name[1..3]);
Console.WriteLine(name.Substring(1, 3));
Console.WriteLine(name.Contains("lic"));
Console.WriteLine(name.StartsWith("al"));
Console.WriteLine(name.StartsWith("al", StringComparison.OrdinalIgnoreCase));
string upper = name.ToUpper();
Console.WriteLine($"{upper} {name}");
string padded = " hello ";
Console.WriteLine($"[{padded.Trim()}] [{padded.TrimStart()}] [{padded.TrimEnd()}]");
$ dotnet run
5
A e
li
lic
True
False
True
ALICE Alice
[hello] [hello ] [ hello]
Two slicing APIs, two meanings: name[1..3] is start-to-end (exclusive), giving li, while Substring(1, 3) is start-and-length, giving lic. Searches are case-sensitive unless you pass a StringComparison. After ToUpper, name is still Alice — the upper-case copy is a separate string.
String Interpolation and Format Specifiers
$"..." evaluates the expressions in braces; a :format suffix controls how each value prints, and ,width pads it.
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
string firstName = "Alice";
int age = 30;
double ratio = 0.9861;
var when = new DateTime(2024, 1, 15, 14, 30, 0);
Console.WriteLine($"Hello, {firstName}! Next year you'll be {age + 1}.");
Console.WriteLine($"{ratio:F1} | {ratio:P1} | {1234.5m:C} | {1234567:N0}");
Console.WriteLine($"{when:yyyy-MM-dd HH:mm} | {when:ddd d MMM}");
Console.WriteLine($"{255:X} {255:D5}");
Console.WriteLine($"{"Name",-8}|{"Score",6}|");
foreach (var (n, s) in new[] { ("Ann", 9.5), ("Robert", 10.0) })
Console.WriteLine($"{n,-8}|{s,6:F1}|");
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
Console.WriteLine($"{1234.5m:C} {ratio:F1}");
$ dotnet run
Hello, Alice! Next year you'll be 31.
1.0 | 98.6% | $1,234.50 | 1,234,567
2024-01-15 14:30 | Mon 15 Jan
FF 00255
Name | Score|
Ann | 9.5|
Robert | 10.0|
1.234,50 € 1,0
F1 rounds 0.9861 to 1.0 — one decimal of a ratio loses almost everything — while P1 multiplies by 100 first, giving 98.6%. A negative width left-aligns and a positive one right-aligns — that is what lines up the table. The last line is the same code under de-DE: comma decimals, dot thousands, and a euro sign. Culture comes from the machine, so set it explicitly whenever output must be predictable.
Escapes, Verbatim and Raw String Literals
Regular strings use backslash escapes, @"..." turns them off, and """...""" raw literals need no escaping at all.
string path1 = "C:\\Users\\Alice\\file.txt";
string path2 = @"C:\Users\Alice\file.txt";
Console.WriteLine(path1 == path2);
string quote = @"She said ""hello"".";
Console.WriteLine(quote);
string name = "Alice";
string json = $$"""
{
"name": "{{name}}",
"tags": ["a\b"]
}
""";
Console.WriteLine(json);
Console.WriteLine("tab:\t| newline:\\n");
$ dotnet run
True
She said "hello".
{
"name": "Alice",
"tags": ["a\b"]
}
tab: | newline:\n
Both paths are the same string; verbatim only changes how you type it. In a raw literal, the closing """ sets the indentation baseline — the four leading spaces are stripped from every line — and \b stays two characters rather than becoming a backspace. $$ means interpolation holes need two braces, so the JSON’s single braces pass through untouched.
Everyday String Methods
Split, join, replace and search cover most day-to-day string work.
string[] words = "one,two,,three".Split(',');
Console.WriteLine($"{words.Length}: [{string.Join("|", words)}]");
string[] clean = "one,two,,three".Split(',', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"{clean.Length}: [{string.Join("|", clean)}]");
Console.WriteLine("foo bar foo".Replace("foo", "baz"));
Console.WriteLine("Hello World".IndexOf("World"));
Console.WriteLine("Hello World".IndexOf("world"));
Console.WriteLine(string.Compare("apple", "Banana", StringComparison.Ordinal));
Console.WriteLine(string.Compare("apple", "Banana", StringComparison.OrdinalIgnoreCase));
Console.WriteLine(string.Equals("ABC", "abc", StringComparison.OrdinalIgnoreCase));
Console.WriteLine(string.IsNullOrEmpty(" "));
Console.WriteLine(string.IsNullOrWhiteSpace(" "));
Console.WriteLine("7".PadLeft(3, '0'));
$ dotnet run
4: [one|two||three]
3: [one|two|three]
baz bar baz
6
-1
31
-1
True
False
True
007
Split keeps the empty string between two adjacent commas unless you ask it not to. A failed IndexOf returns -1, not an exception. The ordinal compare says apple sorts after Banana (positive), because lower-case letters have higher code points than upper-case ones; ignoring case flips the answer. IsNullOrEmpty treats spaces as content, IsNullOrWhiteSpace does not.
StringBuilder
Because strings are immutable, += in a loop copies the whole string each time. StringBuilder appends into a growable buffer instead.
using System.Diagnostics;
using System.Text;
const int N = 20_000;
var sw = Stopwatch.StartNew();
string s = "";
for (int i = 0; i < N; i++) s += i + ",";
Console.WriteLine($"+= {sw.ElapsedMilliseconds,4} ms, {s.Length} chars");
sw.Restart();
var sb = new StringBuilder();
for (int i = 0; i < N; i++) sb.Append(i).Append(',');
string built = sb.ToString();
Console.WriteLine($"StringBuilder {sw.ElapsedMilliseconds,4} ms, {built.Length} chars");
Console.WriteLine(s == built);
var b = new StringBuilder("Hello");
b.Append(", World!").Insert(0, ">>> ").Replace("Hello", "Hi");
Console.WriteLine($"{b} ({b.Length})");
$ dotnet run
+= 474 ms, 108890 chars
StringBuilder 1 ms, 108890 chars
True
>>> Hi, World! (14)
Both produce the same 108,890-character string, but += copies an ever-growing string 20,000 times — quadratic work — while StringBuilder does linear work. The absolute milliseconds depend on your machine; the ratio is the point. StringBuilder methods return the builder itself, so calls chain.
Span<char> for Zero-Allocation Parsing
AsSpan() gives a view into the string’s memory; slicing a span adjusts an offset and length rather than creating a new string.
string csv = "Alice,30,London";
ReadOnlySpan<char> span = csv.AsSpan();
int c1 = span.IndexOf(',');
ReadOnlySpan<char> name = span[..c1];
ReadOnlySpan<char> rest = span[(c1 + 1)..];
int c2 = rest.IndexOf(',');
int age = int.Parse(rest[..c2]);
ReadOnlySpan<char> city = rest[(c2 + 1)..];
Console.WriteLine($"Name: {name}, Age: {age}, City: {city}");
Console.WriteLine(name.SequenceEqual("Alice"));
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) _ = int.Parse(csv.AsSpan(6, 2));
Console.WriteLine($"span parse: {GC.GetAllocatedBytesForCurrentThread() - before} bytes");
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 1000; i++) _ = int.Parse(csv.Substring(6, 2));
Console.WriteLine($"substring parse: {GC.GetAllocatedBytesForCurrentThread() - before} bytes");
$ dotnet run
Name: Alice, Age: 30, City: London
True
span parse: 0 bytes
substring parse: 32000 bytes
int.Parse accepts a span directly, so the age never becomes its own string. The allocation counter makes the difference concrete: a thousand span parses allocate nothing, while a thousand Substring calls allocate a thousand 32-byte strings. That garbage is what Span<char> saves in hot parsing loops.
Regular Expressions
Regex handles patterns that simple string methods cannot: optional parts, repeats, and named captures.
using System.Text.RegularExpressions;
string text = "Order #12345 placed on 2024-01-15 for $99.99";
Console.WriteLine(Regex.IsMatch(text, @"\d{5}"));
Console.WriteLine(Regex.Match(text, @"\d{4}-\d{2}-\d{2}").Value);
var m = Regex.Match(text, @"Order #(?<order>\d+).*?(?<amount>\$[\d.]+)");
Console.WriteLine($"{m.Groups["order"].Value} {m.Groups["amount"].Value}");
string emails = "Contact alice@example.com or bob@test.org";
foreach (Match e in Regex.Matches(emails, @"[\w.+-]+@[\w-]+\.[\w.]+"))
Console.WriteLine(e.Value);
Console.WriteLine(Regex.Replace(" Hello World ", @"\s+", " ").Trim());
Console.WriteLine(Validator.IsValidEmail("a@b.io") + " " + Validator.IsValidEmail("a@b"));
public static partial class Validator
{
[GeneratedRegex(@"^\w+@\w+\.\w+$")]
private static partial Regex EmailPattern();
public static bool IsValidEmail(string s) => EmailPattern().IsMatch(s);
}
$ dotnet run
True
2024-01-15
12345 $99.99
alice@example.com
bob@test.org
Hello World
True False
Named groups (?<order>) are read back by name, so reordering the pattern does not break the code that uses it. The lazy .*? stops at the first $. [GeneratedRegex] (.NET 7+) makes the compiler generate the matching code at build time, so there is no runtime regex compilation and the pattern is checked when you build.
Practice
- Title case by hand. Turn
"the quick brown fox"into"The Quick Brown Fox"usingSplit, indexing andstring.Join— noTextInfo.
Solution
string s = "the quick brown fox";
var words = s.Split(' ').Select(w => char.ToUpper(w[0]) + w[1..]);
Console.WriteLine(string.Join(" ", words));
$ dotnet run
The Quick Brown Fox
char + string concatenates, so char.ToUpper(w[0]) + w[1..] rebuilds each word.
- Palindrome check. Ignoring case, spaces and punctuation, is
"A man, a plan, a canal: Panama"a palindrome?
Solution
string s = "A man, a plan, a canal: Panama";
var letters = s.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray();
string forward = new string(letters);
Array.Reverse(letters);
Console.WriteLine(forward == new string(letters));
$ dotnet run
True
- Invoice line. Print
Widget x3 @ 4.5 = 13.50right-aligned in a 30-character column, formatting the total with two decimals and invariant culture.
Solution
using System.Globalization;
string item = "Widget";
int qty = 3;
decimal price = 4.5m;
string line = string.Create(CultureInfo.InvariantCulture, $"{item} x{qty} @ {price} = {qty * price:F2}");
Console.WriteLine($"[{line,30}]");
$ dotnet run
[ Widget x3 @ 4.5 = 13.50]
string.Create(IFormatProvider, ...) formats an interpolated string under a specific culture.
- Pull out the key-value pairs. From
"host=db1; port=5432; user=app", use a regex with named groups to build aDictionary<string, string>.
Solution
using System.Text.RegularExpressions;
string conn = "host=db1; port=5432; user=app";
var dict = Regex.Matches(conn, @"(?<key>\w+)=(?<value>[^;]+)")
.ToDictionary(m => m.Groups["key"].Value, m => m.Groups["value"].Value);
foreach (var (k, v) in dict)
Console.WriteLine($"{k} -> {v}");
$ dotnet run
host -> db1
port -> 5432
user -> app
[^;]+ stops each value at the next semicolon.