Strings and String Interpolation
Working with text in C#: string literals and escapes, interpolation with $ and format specifiers, verbatim and raw string literals, common string methods, StringBuilder, and culture-aware formatting.
String literals and escapes
A string literal is text
between double quotes. Single quotes are for a single
char, not for strings. Inside a normal string literal,
the backslash starts an
escape sequence:
| Escape | Meaning |
|---|---|
\n |
New line |
\t |
Tab |
\" |
Double quote |
\\ |
Backslash |
\' |
Single quote (needed in a char) |
\u20AC |
The character with UTF-16 code 20AC (hex), here € |
\0 |
The null character (code 0) |
Console.WriteLine("Ticket INC-20417\nStatus:\topen");
Console.WriteLine("The customer said \"no power since 08:00\".");
Console.WriteLine("Log folder: C:\\Kestrel\\logs");
Console.WriteLine("Tariff in \u20AC per kWh");
char separator = ';';
Console.WriteLine(separator);
Ticket INC-20417 Status: open The customer said "no power since 08:00". Log folder: C:\Kestrel\logs Tariff in € per kWh ;
An empty string is "" (or string.Empty),
which is different from null, meaning "no string at
all".
Length and indexing
Length gives the number of characters, and
s[i] the char at position i,
counting from 0. s[^1] counts from the end, so it is
the last character, and s[..3] is the first three
characters (indices and ranges are covered in
Loops).
string ticket = "INC-20417";
Console.WriteLine(ticket.Length);
Console.WriteLine(ticket[0]);
Console.WriteLine(ticket[^1]);
Console.WriteLine(ticket[..3]);
Console.WriteLine(ticket[4..]);
Console.WriteLine("Ångström".Length);
Console.WriteLine("🔌".Length);
9 I 7 INC 20417 8 2
The last line surprises many people. A .NET string is a sequence of
UTF-16
code units, and characters outside the Basic Multilingual Plane,
such as most emoji, take two of them. Length counts
code units, not what a reader sees as characters. For ordinary
Western text the two are the same. An index outside the string
throws an IndexOutOfRangeException.
Strings are immutable
A string cannot be changed after it is created: it is
immutable. Methods
such as ToUpper or Replace return a
new string and leave the original alone, so you must use
the return value. Assigning s[0] = 'X' does not even
compile.
string region = "north";
region.ToUpper(); // result thrown away
Console.WriteLine(region);
region = region.ToUpper(); // keep the new string
Console.WriteLine(region);
north NORTH
Immutability is also why sharing a string between variables is safe,
even though string is a
reference type: nobody can change it under your feet.
String interpolation
You can join strings with +, but text with several
values in it quickly becomes hard to read. An
interpolated string
starts with $; any C# expression between braces
{ } is evaluated and its text inserted. This is C#'s
form of
string interpolation, the counterpart of Python's f-strings and JavaScript's template
literals.
string ticket = "INC-20417";
string region = "North";
int customers = 1250;
// Concatenation
Console.WriteLine("Ticket " + ticket + " in " + region + ": " + customers + " customers");
// Interpolation: same result, easier to read
Console.WriteLine($"Ticket {ticket} in {region}: {customers} customers");
// Any expression works, including method calls
Console.WriteLine($"{region.ToUpper()} has {customers / 1000.0} thousand affected");
Console.WriteLine($"Priority: {(customers >= 1000 ? "P1" : "P3")}");
Console.WriteLine($"Use {{braces}} to write a literal brace: {{{customers}}}");
Ticket INC-20417 in North: 1250 customers
Ticket INC-20417 in North: 1250 customers
NORTH has 1.25 thousand affected
Priority: P1
Use {braces} to write a literal brace: {1250}
Two details. A conditional expression must be wrapped in
parentheses, because its : would otherwise be read as
the start of a format specifier. And a literal brace is written
twice: {{ and }}.
Format specifiers
After the expression, a colon and a
format specifier control how
the value is turned into text: {amount:F2} means
"fixed-point, two decimals". The same codes work in
ToString("F2") and string.Format. The
output below uses the invariant culture, which is explained
at the end of this page; in another culture the decimal separator,
thousands separator and currency sign can differ.
| Specifier | Meaning | Example |
|---|---|---|
F2 |
Fixed-point, 2 decimals | 3.14 |
N0, N2 |
Number with thousands separators | 1,234.50 |
C |
Currency of the culture | ¤1,234.50 (invariant) |
P1 |
Percentage (value × 100), 1 decimal | 25.6 % (invariant) |
D5 |
Integer, padded with zeros to 5 digits | 00042 |
X, X4 |
Hexadecimal | FF, 00FF |
E2 |
Scientific notation | 1.23E+004 |
yyyy-MM-dd HH:mm |
Custom date and time pattern | 2026-10-03 14:05 |
decimal monthly = 1234.5m;
double share = 0.256;
int ticketNumber = 42;
DateTime reported = new DateTime(2026, 10, 3, 14, 5, 0);
Console.WriteLine($"{Math.PI:F2}");
Console.WriteLine($"{monthly:N2}");
Console.WriteLine($"{monthly:N0}");
Console.WriteLine($"{monthly:C}");
Console.WriteLine($"{share:P1}");
Console.WriteLine($"INC-{ticketNumber:D5}");
Console.WriteLine($"{255:X} {255:X4}");
Console.WriteLine($"{12345.678:E2}");
Console.WriteLine($"{reported:yyyy-MM-dd HH:mm}");
Console.WriteLine(monthly.ToString("F2"));
3.14 1,234.50 1,235 ¤1,234.50 25.6 % INC-00042 FF 00FF 1.23E+004 2026-10-03 14:05 1234.50
In custom date patterns case matters: MM is the month
and mm the minutes; HH is the 24-hour
clock and hh the 12-hour clock. For dates in data,
prefer the ISO 8601 order yyyy-MM-dd; the
"o" specifier gives the full round-trip form. Dates are
covered further in
Files, JSON and the .NET Libraries.
Alignment
A comma and a number after the expression set a minimum width: positive numbers align right, negative numbers align left. Combined with a format specifier, this gives tidy columns in console output and log files:
(string Id, string Region, int Customers, double Hours)[] outages =
[
("INC-20417", "North", 1250, 2.5),
("INC-20418", "East", 85, 0.75),
("INC-20421", "South-West", 430, 11.25),
];
Console.WriteLine($"{"Ticket",-10} {"Region",-11} {"Customers",9} {"Hours",6}");
foreach (var o in outages)
{
Console.WriteLine($"{o.Id,-10} {o.Region,-11} {o.Customers,9:N0} {o.Hours,6:F2}");
}
Ticket Region Customers Hours INC-20417 North 1,250 2.50 INC-20418 East 85 0.75 INC-20421 South-West 430 11.25
The list holds tuples, small groups of named values, introduced in Records, Structs and Enums. A value longer than the width is never cut off; the column just gets wider.
Verbatim string literals
Windows paths and regular expressions are full of backslashes, and
doubling every one of them is error-prone. A
verbatim string literal
starts with @: backslashes are ordinary characters, the
string may span several lines, and the only escape is a doubled
quote "" for one ".
string normal = "C:\new\temp"; // \n and \t are escapes!
string verbatim = @"C:\new\temp"; // backslashes kept
Console.WriteLine(normal);
Console.WriteLine(verbatim);
string note = @"Customer said ""call me back"".
Second line of the note.";
Console.WriteLine(note);
string share = @"\\fileserver\meters";
Console.WriteLine($@"Reading from {share}\2026");
C: ew emp C:\new\temp Customer said "call me back". Second line of the note. Reading from \\fileserver\meters\2026
The first line shows the bug verbatim strings prevent:
"C:\new\temp" contains a line break and a tab. The
prefixes combine: $@"…" (or @$"…") is
verbatim and interpolated. For building file paths,
Path.Combine is even safer, because it uses the right
separator on each operating system.
Raw string literals
A raw string literal (C# 11) starts and ends with at least three double quotes. Nothing inside is an escape: quotes, backslashes and braces are all literal text. In the multi-line form the opening and closing quotes are on their own lines, and the indentation of the closing quotes is removed from every line, so the text can be indented along with your code.
string text = """
{
"ticket": "INC-20417",
"region": "North",
"path": "C:\logs\outage.txt"
}
""";
Console.WriteLine(text);
string quote = """He said "it's back" and hung up.""";
Console.WriteLine(quote);
{
"ticket": "INC-20417",
"region": "North",
"path": "C:\logs\outage.txt"
}
He said "it's back" and hung up.
Raw strings can be interpolated too. Because JSON itself uses
braces, you put two $ signs in front; then an
expression needs two braces {{ }}, and a single brace
is literal text:
string ticket = "INC-20418";
int customers = 85;
string body = $$"""
{"ticket": "{{ticket}}", "customers": {{customers}}, "major": {{(customers >= 1000 ? "true" : "false")}}}
""";
Console.WriteLine(body);
{"ticket": "INC-20418", "customers": 85, "major": false}
Common string methods
The string type has many methods. These are the ones
you will use daily; all return new values and leave the original
string unchanged.
| Method | Returns |
|---|---|
Trim(), TrimStart(),
TrimEnd()
|
The string without surrounding whitespace |
ToUpper(), ToLower() |
The string in upper or lower case |
Contains(x), StartsWith(x),
EndsWith(x)
|
true or false |
IndexOf(x) |
Position of the first match, or -1 |
Substring(start, length) |
Part of the string |
Replace(old, new) |
The string with every match replaced |
Split(separator) |
An array of the parts |
string.Join(separator, items) |
One string from many |
PadLeft(n, c), PadRight(n, c)
|
The string padded to length n |
string raw = " INC-20417: Power outage in North ";
string text = raw.Trim();
Console.WriteLine($"[{text}]");
Console.WriteLine(text.StartsWith("INC-"));
Console.WriteLine(text.Contains("outage"));
Console.WriteLine(text.Contains("Outage"));
Console.WriteLine(text.IndexOf(':'));
Console.WriteLine(text.Substring(0, 9));
Console.WriteLine(text.Replace("North", "North-East"));
Console.WriteLine("7".PadLeft(3, '0'));
[INC-20417: Power outage in North] True True False 9 INC-20417 INC-20417: Power outage in North-East 007
Split and string.Join are the pair for
simple delimited text, such as a line from a meter export:
string line = "EAN871687;2026-10-02;342;North";
string[] fields = line.Split(';');
Console.WriteLine(fields.Length);
Console.WriteLine($"Meter {fields[0]} used {fields[2]} kWh on {fields[1]}");
string tags = " storm , cable, North ";
string[] parts = tags.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(string.Join("|", parts));
4 Meter EAN871687 used 342 kWh on 2026-10-02 storm|cable|North
For real CSV files, with quoted fields that may contain the
separator, use a CSV library instead of Split.
Substring throws an exception when the range does not
fit inside the string; check the Length first:
string ticket = "INC-417";
Console.WriteLine(ticket.Substring(4, 3));
Console.WriteLine(ticket.Substring(4, 5));
417 Unhandled exception. System.ArgumentOutOfRangeException: Index and length must refer to a location within the string. (Parameter 'length')
Comparing strings
== compares the text of two strings, character by
character, and is case-sensitive. For case-insensitive comparison,
pass a StringComparison value instead of calling
ToLower() on both sides.
OrdinalIgnoreCase compares the character codes,
ignoring case, which is right for identifiers, file names and codes.
string.Compare
tells you which string sorts first.
string fromForm = "north";
string region = "North";
Console.WriteLine(fromForm == region);
Console.WriteLine(string.Equals(fromForm, region, StringComparison.OrdinalIgnoreCase));
Console.WriteLine("INC-20417".StartsWith("inc", StringComparison.OrdinalIgnoreCase));
Console.WriteLine(string.Compare("East", "North", StringComparison.Ordinal) < 0);
string? note = " ";
Console.WriteLine(string.IsNullOrWhiteSpace(note));
False True True True True
string.IsNullOrEmpty and
string.IsNullOrWhiteSpace are the standard checks for
"is there any real text?", and they work when the string is
null.
StringBuilder
Because strings are immutable, report += line creates a
new string each time and copies all the text so far. In a loop over
thousands of lines that becomes slow. A
StringBuilder (in the namespace
System.Text) is a mutable buffer for text: you append
pieces to it and create the final string once with
ToString().
using System.Text;
int[] dailyKwh = [11, 9, 12, 10, 13, 8, 7];
string[] days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
var report = new StringBuilder();
report.AppendLine("Weekly usage");
for (int i = 0; i < days.Length; i++)
{
report.Append(days[i]).Append(": ").Append(new string('#', dailyKwh[i]));
report.AppendLine($" {dailyKwh[i]} kWh");
}
report.Append($"Total: {dailyKwh.Sum()} kWh");
Console.WriteLine(report.ToString());
Weekly usage Mon: ########### 11 kWh Tue: ######### 9 kWh Wed: ############ 12 kWh Thu: ########## 10 kWh Fri: ############# 13 kWh Sat: ######## 8 kWh Sun: ####### 7 kWh Total: 70 kWh
For a handful of pieces, ordinary interpolation or
+ is fine and easier to read; the compiler already
optimizes simple cases. Reach for StringBuilder when
you add to the same text inside a loop. To join items from a
collection with a separator, string.Join is shorter
still.
Culture-aware formatting
How a number or date looks depends on the reader's conventions. In
the Netherlands one thousand two hundred thirty-four euros and fifty
cents is written € 1.234,50; in the United States a
similar amount in dollars is $1,234.50. .NET describes
such conventions with a culture, an object of type
CultureInfo (namespace
System.Globalization) named after a language and
region, such as nl-NL or en-US. Every
thread has a current culture, taken from the operating
system's settings, and formatting and parsing use it unless you pass
another one.
The
invariant culture
(CultureInfo.InvariantCulture) belongs to no country
and never changes. It uses a dot for decimals, commas for thousands,
the generic currency sign ¤ and month-first dates. It
is meant for data that programs exchange: files, logs, APIs and
configuration. The examples on these pages run with the invariant
culture as current culture, which is why C printed
¤ above.
using System.Globalization;
decimal amount = 1234.5m;
DateTime outage = new DateTime(2026, 10, 3, 14, 5, 0);
CultureInfo dutch = new CultureInfo("nl-NL");
CultureInfo invariant = CultureInfo.InvariantCulture;
Console.WriteLine(amount.ToString("C", dutch));
Console.WriteLine(amount.ToString("N2", dutch));
Console.WriteLine(outage.ToString("D", dutch));
Console.WriteLine(outage.ToString("d", dutch));
Console.WriteLine(amount.ToString("N2", invariant));
Console.WriteLine(outage.ToString(invariant));
Console.WriteLine(string.Create(dutch, $"Bill: {amount:C} on {outage:d}"));
€ 1.234,50 1.234,50 zaterdag 3 oktober 2026 03-10-2026 1,234.50 10/03/2026 14:05:00 Bill: € 1.234,50 on 03-10-2026
string.Create(culture, $"…") formats a whole
interpolated string with one culture. Parsing works the same way in
reverse, and that is where the real bugs are: the same text means
different numbers in different cultures.
using System.Globalization;
CultureInfo dutch = new CultureInfo("nl-NL");
CultureInfo invariant = CultureInfo.InvariantCulture;
Console.WriteLine(double.Parse("1,5", dutch));
Console.WriteLine(double.Parse("1.5", invariant));
Console.WriteLine(decimal.Parse("1.5", dutch)); // dot = thousands separator!
// A meter export always uses a dot: parse it with the invariant culture
string fromFile = "342.75";
decimal kwh = decimal.Parse(fromFile, invariant);
Console.WriteLine(kwh * 2);
1.5 1.5 15 685.50