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