Skip to main content
C# · lesson 8 of 26
C# beginner 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

  1. Title case by hand. Turn "the quick brown fox" into "The Quick Brown Fox" using Split, indexing and string.Join — no TextInfo.
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.

  1. 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
  1. Invoice line. Print Widget x3 @ 4.5 = 13.50 right-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.

  1. Pull out the key-value pairs. From "host=db1; port=5432; user=app", use a regex with named groups to build a Dictionary<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.

Frequently Asked Questions

Why is string concatenation in a loop bad?
Each + on a string creates a new string object because strings are immutable. A loop that concatenates N times creates N intermediate strings, giving O(N²) time and memory. Use StringBuilder for loops.
What is the difference between string and String?
They are the same type. 'string' is a C# keyword alias for System.String. Use 'string' for variables and 'String' only when calling static methods (String.Format, String.IsNullOrEmpty) — though the lowercase versions work fine too.
When should I use Span<char> over string?
Use Span<char> in performance-critical code where you need to work with a substring without allocating a new string. Span<char> is a stack-only view into existing memory, so it is zero-allocation.
Why does {price:C} print a different currency symbol on another machine?
Format specifiers like C, N and dates use the current culture, which comes from the operating system or container. The same code prints $1,234.50 under en-US and 1.234,50 € under de-DE. Pass an explicit CultureInfo, or CultureInfo.InvariantCulture for machine-readable output, whenever the result must not vary.
Should I use == or string.Equals to compare strings in C#?
== on two string-typed values compares contents, ordinally and case-sensitively, so it is fine for exact matches. For case-insensitive comparison call string.Equals(a, b, StringComparison.OrdinalIgnoreCase) rather than a.ToLower() == b.ToLower(), which allocates two new strings and can misbehave in cultures such as Turkish.