Strings in C#: Introduction, and Best Practices
Table of Contents
- Introduction
- What are C# strings?
- C# strings and Unicode
- Declaring strings in C#
- 1. String literals
- 2. Verbatim string literals
- 3. Raw String literals
- 4. Interpolated Strings
- 5. Verbatim String literals
- Formatting C# strings
- Composite formatting
- Alignment and common format specifiers
- IFormatProvider for culture control
- Combining C# strings
- Concatenation and interpolation
- string.Concat and string.Join
- Equals, ==, ReferenceEquals, StringComparison, and case sensitivity in C# strings
- == operator versus Equals method
- ReferenceEquals
- StringComparison and case sensitivity
- Making C# strings culture aware or culture agnostic
- Formatting with culture
- Comparisons and casing with culture
- C# strings and immutability
- Immutable string benefits
- Common operations performed on C# strings
- Trim
- Replace
- Split
- StartsWith and EndsWith
- Contains
- Substring
- IndexOf and LastIndexOf
- string.Empty, empty strings, and null checks
- string.IsNullOrEmpty
- string.IsNullOrWhiteSpace
- StringBuilder in C#
- Summary
Introduction
In this article we will outline one of the most common types you will use in C#, the string. You will work with strings any time you deal with user input, configuration values, file paths, log messages, returning data from APIs, or anything else that needs to be represented as text.
We will begin by outlining what C# strings are under the hood, where Unicode fits in, followed by how to construct them using normal string literals, verbatim literals, and raw string literals. Then we will explore formatting strings and how to combine and inspect strings, how string comparisons work, and how to handle culture and case sensitivity. We'll finish with why immutability matters, an outline of common operations you would perform on strings, a short section on empty strings, null, and input validation, and finally when it is worth using the StringBuilder class when building strings.
What are C# strings?
A string in C# represents data such as names, messages, and any other data you can think of that can be stored in a textual format. In C#, strings are class instances of the System.String and, compared to most other C# classes, are immutable. Immutability means that once you create a string, its contents never change, and any changes to a string result in a new string being created to replace the old one.
Strings are used in almost every C# application you will build, from simple console programs up to large web services. Strings are used to pass data between layers, represent values returned from APIs or databases, log what your application is doing, and hold identifiers or tokens. Strings are reference types, so variables of the type string hold a reference to the underlying instance, and as stated above, strings are immutable, therefore you may share the same string between multiple variables, methods, and even threads safely, without being concerned that one part of your code will change that string to something else.
C# strings and Unicode
Under the hood, C# strings store text using UTF-16, which is an encoding that can represent the full range of Unicode characters used by many languages, with the addition of emoji and symbols. Instead of thinking of a string as a simple list of characters, it is better to think of it as a sequence of UTF-16 code units, where some characters are made up of a single code unit, and others need two or more.
This detail matters when you work with string properties such as Length, or when you access an index of a string, such as mySuperAwesomeString[index]. The Length property tells you how many UTF-16 code units are in the string, which is not always the same as the number of user-visible characters, especially when considering emojis or combining characters.
We go through code units and UTF-16 in greater detail and provide examples in the Reverse a string C# extension method article.
Declaring strings in C#
1. String literals
String literals represent a fixed text value rather than a variable name.
string name = "Dan Doyle";
string message = "Hello, World!";
In the two examples of declaring a string literal above, we declare two variables of the type string, and assign them literal text values. Each line follows the same pattern of string <variable name> = "<text value>", where name and message are variable names that hold references to the underlying string class instances.
Handling escape characters in string literals
When writing string literals, some characters have a special meaning to the compiler and are treated as language syntax instead of just text.
Escaping string literals allows you to include those characters safely by telling the compiler to treat those characters as literal strings. This is important because failing to escape those special characters can result in compilation errors.
string filePath = "C:\\Users\\DanDoyle\\Documents\\speech.txt";The example above creates a string variable containing a file path. Because the backslash character starts escape sequences in C# string literals, each backslash indicating a directory separator must be written as \\.
Each backslash pair represents one literal backslash character in the resulting string value, therefore filePath ends up containing the correctly formatted file path.
2. Verbatim string literals
Verbatim string literals allow you to represent text exactly as written, without the compiler requiring or interpreting escape sequences. These are declared by prefixing the string with a @ symbol. Verbatim string literals are useful when working with file paths, regular expressions, or multi-line content.
string filePath = @"C:\Users\DanDoyle\Documents\speech.txt";
string multiLineLiteral = @"Line one
Line two
Line three";In the two examples above, the backslash characters do not need escaping, and line breaks are preserved as part of the string value. The only character that must still be escaped is the double quote, which is represented by using two double quotes.
string quote = @"She said ""hello"" to the room.";Verbatim literals are used where there are excessive escape characters, making them a useful option when working with structured or formatted text.
3. Raw String literals
Raw string literals are a step up from verbatim string literals, and they allow you to ignore escaping entirely. They were introduced in C# 11, and they are declared using triple quotes at the beginning and end. Raw string literals preserve formatting, whitespace, and quotation marks exactly as written.
string json = """
{
"name": "Dan Doyle",
"role": "Developer"
}
""";As you can see above in the example, raw string literals are useful for embedding JSON, SQL, or any content that would otherwise require a large amount of escaping.
4. Interpolated Strings
Interpolated strings are a way to compose strings by putting expressions directly inside string literals. They are declared using the $ prefix, and allow expressions to be evaluated inline using braces.
string name = "Dan";
int unreadMessages = 3;
string message = $"Hello {name}, you have {unreadMessages} unread messages.";Expressions inside interpolation braces can include method calls, calculations, or formatting instructions.
decimal total = 42.5m;
string summary = $"Total due: {total:C2}";In the example above, we declare a decimal named total, and assign it the value 42.5m, where the m suffix indicates the literal is a decimal rather than another type such as the double. The second line constructs a string using interpolation, indicated by the $ prefix.
Inside the interpolation braces, {total:C2} inserts the value of total into the string and applies a numeric format specifier. The C formats the value as currency using the current culture settings, while 2 specifies two decimal places.
5. Verbatim String literals
Verbatim string literals can also be combined with interpolation, enabling you to insert values while still following verbatim string literal escaping behaviour. This is achieved by using the $@ or @$ prefix before the opening quote.
string directory = "Documents";
string fileName = "document.txt";
string fullPath = $@"C:\Users\DanDoyle\{directory}\{fileName}";In the example above, interpolation placeholders are evaluated, while the rest of the string remains verbatim. This combination is useful when constructing paths, templates, or messages that mix literal string text with runtime values.
Formatting C# strings
Formatting strings is about taking values such as numbers, dates, and custom types, and turning them into readable text. In C#, you can do this with composite formatting, interpolation format specifiers, and by supplying an IFormatProvider when you need control over culture.
Composite formatting
Composite formatting uses placeholders such as {0}, {1}, and so on. The placeholders refer to arguments you pass to string.Format (or Console.WriteLine, which supports the same style of formatting).
string name = "Bob";
int unread = 3;
decimal balance = 42m;
string message = string.Format("Hello {0}, you have {1} unread messages. Balance: {2:C2}", name, unread, balance);
Console.WriteLine(message); // Output: Hello Bob, you have 3 unread messages. Balance: £42.00Notice the :C2 part. That is a format specifier. It tells C# how to format the value. In this case it formats as currency with 2 decimal places, using the current culture.
Common format specifiers
The table below lists some of the most common format specifiers you will see in C#. It shows what each specifier means, how it looks in a format string, and an example of the output it produces. The exact output can vary depending on the current culture, especially for currency and date formats.
| Specifier | Meaning | Example | Sample output |
|---|---|---|---|
C / C2 | Currency (optionally with decimal places) | {0:C2} | £1,234.56 |
D | Long date pattern | {0:D} | 18 February 2026 |
D / D6 | Decimal integer with leading zeros | {0:D6} | 000042 |
d | Short date pattern | {0:d} | 18/02/2026 |
F / F2 | Fixed-point (optionally with decimal places) | {0:F2} | 1234.56 |
N / N0 | Number with thousands separators (optionally decimals) | {0:N0} | 1,235 |
O (or o) | Round-trip date/time (ISO 8601 style) | {0:O} | 2026-02-18T09:30:00.0000000Z |
P / P0 | Percentage (multiplies by 100) | {0:P0} | 20% |
X / X8 | Hexadecimal (uppercase) with optional padding | {0:X8} | 0000002A |
| Custom | Custom patterns for dates and times | {0:yyyy-MM-dd HH:mm} | 2026-02-18 09:30 |
See the standard numeric format strings and standard date and time format strings pages from Microsoft Learn.
Alignment and common format specifiers
You can also align values inside placeholders. This is useful for console output and simple text tables. Positive alignment values right-align, negative values left-align.
int id = 7;
string product = "Mouse";
decimal price = 42m;
string row = string.Format("{0,4} | {1,-12} | {2,8:C2}", id, product, price);
Console.WriteLine(row);
// Example output:
// 7 | Mouse | £42.00The example above uses composite formatting to build a table row for console output. The format string contains three placeholders, {0}, {1}, and {2}, which map to id, product, and price in that order. The alignment values control how each value is padded, so {0,4} right aligns the id within 4 characters, {1,-12} left aligns the product name within 12 characters, and {2,8:C2} right aligns the price within 8 characters, while also formatting it as currency with 2 decimal places. The result is a consistently spaced row that lines up properly when you output multiple rows underneath each other.
Leading zeros, number formatting, percentages, and date patterns
A few format specifiers you will use often are: D for decimals with leading zeros, N for number with separators, P for percentage, F for fixed-point, C for currency, and standard date specifiers such as d, D, O, and custom patterns like yyyy-MM-dd.
int orderNumber = 42;
decimal taxRate = 0.2m;
DateTime createdOn = new DateTime(2026, 2, 18, 9, 30, 0);
Console.WriteLine($"Order: {orderNumber:D6}");
Console.WriteLine($"Tax: {taxRate:P0}");
Console.WriteLine($"Created: {createdOn:yyyy-MM-dd HH:mm}");
// Output:
// Order: 000042
// Tax: 20%
// Created: 2026-02-18 09:30The example above demonstrates number formatting, percentages, and date patterns formatting. {orderNumber:D6} pads the order number with leading zeros so it always outputs as six digits, which is useful for IDs that you want to line up or sort predictably. {taxRate:P0} formats the decimal as a percentage by multiplying by 100 and rounding to zero decimal places, turning 0.2 into 20%. Finally, {createdOn:yyyy-MM-dd HH:mm} uses a custom date and time pattern.
IFormatProvider for culture control
If you are generating output for logs, or anything that should not change depending on the server culture, you should pass an IFormatProvider parameter such as CultureInfo.InvariantCulture. If you are generating output for users, you usually want their culture.
using System.Globalization;
decimal total = 1234.56m;
string invariant = string.Format(CultureInfo.InvariantCulture, "Total: {0}", total);
string uk = string.Format(CultureInfo.GetCultureInfo("en-GB"), "Total: {0:C2}", total);
string de = string.Format(CultureInfo.GetCultureInfo("de-DE"), "Total: {0:C2}", total);
Console.WriteLine(invariant);
Console.WriteLine(uk);
Console.WriteLine(de);
// Output:
// Total: 1234.56
// Total: £1,234.56
// Total: 1.234,56 €The example above demonstrates why culture is important when you format values into strings. The same decimal value is formatted three different ways by passing a different IFormatProvider into string.Format. Using CultureInfo.InvariantCulture outputs a culture agnostic result, which stays consistent across machines. The en-GB and de-DE cultures format the same value as currency using {0:C2}, but the output changes based on local rules, such as the currency symbol and the thousands and decimal separators.
Combining C# strings
Combining strings is one of the most common things you will do in C#. The main options are concatenation with the + operator, interpolation, string.Concat, or using string.Join when you are joining collections with a separator.
Concatenation and interpolation
For a small number of values, using the + operator is efficient. Most of the time though, interpolation is clearer and easier to maintain.
<snippet data-language="csharp">string firstName = "Dan";
string lastName = "Doyle";
string fullName1 = firstName + " " + lastName;
string fullName2 = $"{firstName} {lastName}";
Console.WriteLine(fullName1);
Console.WriteLine(fullName2);</snippet>The example above shows two simple ways to combine strings. fullName1 uses concatenation with the + operator, where you manually include the space between the values. fullName2 uses string interpolation, which is easier to read, because you can write the final output in one place and just insert the variables where you need them. Both output the same result, but interpolation tends to scale better once you start adding more values to the message.
You can read more about interpolation above
string.Concat and string.Join
string.Concat
string.Concat is useful if you already have separate parts, and you just want them joined together without a separator. For lists, string.Join is usually what you want.
string firstName = "Dan";
string lastName = "Doyle";
// Concat joins values with no separator.
string fullName = string.Concat(firstName, lastName);
Console.WriteLine(fullName);
// Output:
// DanDoyle
// Join inserts a separator between each item in the collection.
string[] tags = new[] { "csharp", "strings", "formatting" };
string csv = string.Join(", ", tags);
Console.WriteLine(csv);
// Output:
// csharp, strings, formattingIn the example above it shows the difference between string.Concat and string.Join. Concat is preferable when you already have the exact parts you want and you do not need anything inserted between them, so it joins the values together with a delimiter. Join on the other hand is designed for collections and takes a separator, so it handles the cases where you want to insert something between each item.
string.Join
string.Join may be used to build comma-separated strings, log messages, or any output where you want a delimiter.
string[] parts = { "C:", "Users", "DanDoyle", "Documents", "test.txt" };
string path = string.Join("\\", parts);
Console.WriteLine(path);
// Output:
// C:\Users\DanDoyle\Documents\test.txtThe example above uses string.Join to build a file path from an array of segments. The first argument is the separator, which needs to be a single backslash, so in C# you write it as "\\". string.Join then inserts that separator between every value in parts, which gives you a correctly constructed path without having to manually concatenate strings and keep track of where the slashes should go.
Equals, ==, ReferenceEquals, StringComparison, and case sensitivity in C# strings
Comparing strings in C# is one of those features in C# where it is easy to introduce subtle bugs. The two important things to keep in mind are case sensitivity and culture.
== operator versus Equals method
In C#, the == operator for strings compares the text content, and not the object reference. It behaves similarly to string.Equals with a default case-sensitive comparison. That means this works the way people expect an equality comparison to work.
string a = "hello";
string b = "hello";
string c = "HELLO";
Console.WriteLine(a == b);
Console.WriteLine(a.Equals(b));
Console.WriteLine(a == c);
// Output:
// True
// True
// FalseThe example above compares three strings using the == operator and Equals method. The first two variables contain the same text, so both comparisons return true, whereas the third variable uses different casing, and because these comparisons are case sensitive by default, a == c returns false. If you need case insensitivity, you can use the overload of string.Equals that accepts a StringComparison.
ReferenceEquals
object.ReferenceEquals checks whether two variables point to the same instance in memory. Because strings are immutable, the runtime may reuse string instances in some cases, but you should not rely on that behaviour. In most code, you want to compare string contents, not references.
string a = "hello";
string b = new string(a);
Console.WriteLine(a == b);
Console.WriteLine(object.ReferenceEquals(a, b));
// Output:
// True
// FalseThe example above creates two strings that contain the same text, but are different objects. The == operator compares the string contents, so it returns true. object.ReferenceEquals checks whether both variables point to the same instance, so it returns false. This is why ReferenceEquals is rarely what you want for normal string comparisons.
StringComparison and case sensitivity
If you are comparing identifiers, keys, headers, file extensions, URLs, or anything that should be treated as a binary match, you should use ordinal comparisons. By binary match, I mean you want the comparison to behave like a direct byte or code unit comparison, where the text either matches or it does not, with no language rules involved. This is important for values that act like tokens in your system, such as dictionary keys, configuration values, HTTP header names, file extensions, and route segments. On the other hand, if you are comparing user-facing text, you should respect culture, because cultural rules can affect casing, sorting, and what is considered equal.
string left = "file.txt";
string right = "FILE.TXT";
Console.WriteLine(string.Equals(left, right, StringComparison.Ordinal));
Console.WriteLine(string.Equals(left, right, StringComparison.OrdinalIgnoreCase));
// Output:
// False
// TrueThe example above compares the same filename using two different comparison modes. StringComparison.Ordinal is case sensitive, so it treats file.txt and FILE.TXT as different values. StringComparison.OrdinalIgnoreCase performs a case-insensitive ordinal comparison, which is the right choice for things like file extensions and identifiers.
As a general rule, StringComparison.OrdinalIgnoreCase is a good default for case-insensitive comparisons that don't need to be aware of culture.
Making C# strings culture aware or culture agnostic
Culture in C# is important when you format numbers and dates, convert case, compare strings, or sort them. Culture rules can change the meaning of operations, so you need to pick the right behaviour for your scenario.
If the output is for a user, culture-aware formatting is usually correct. If the output is for storage, logs, APIs, or something like hashing, culture-agnostic is usually correct.
Formatting with culture
using System.Globalization;
decimal amount = 1234.56m;
string forUkUser = amount.ToString("C2", CultureInfo.GetCultureInfo("en-GB"));
string forUsUser = amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
string forMachine = amount.ToString(CultureInfo.InvariantCulture);
Console.WriteLine(forUkUser);
Console.WriteLine(forUsUser);
Console.WriteLine(forMachine);
// Output:
// £1,234.56
// $1,234.56
// 1234.56The example above formats the same numeric value three different ways by passing a different IFormatProvider. The UK and US examples format the value as currency using C2, but the symbol and separators are culture-specific. The invariant example outputs a consistent representation that does not change depending on machine settings, which makes it safer for logs and APIs.
Invariant culture is a fixed, culture-agnostic set of formatting rules that never changes based on the machine or the user. When you use CultureInfo.InvariantCulture, you are saying, format this value in a predictable way that is safe for code to read back later. That is why forMachine outputs 1234.56 with a dot decimal separator and no currency symbol. This is the kind of output you want for logs, storage, or APIs, where consistency matters more than displaying the value in a way that looks natural to a user.
Comparisons and casing with culture
Case conversion and comparisons are where culture can trip you up. If you need consistency across machines and environments, use ordinal comparisons and InvariantCulture where appropriate.
using System.Globalization;
string value = "istanbul";
string invariantUpper = value.ToUpperInvariant();
string britishUpper = value.ToUpper(CultureInfo.GetCultureInfo("en-GB"));
Console.WriteLine(invariantUpper);
Console.WriteLine(britishUpper);The example above shows that casing can change depending on the culture rules being used. The ToUpperInvariant method uses culture-agnostic formatting, so it stays consistent across environments. When you pass a specific culture, the output may change based on that culture's casing rules, which can affect string comparisons.
The exact output depends on the culture rules. If you are comparing user input against a known command such as start or stop, you do not want those rules involved, therefore use StringComparison.OrdinalIgnoreCase .
When you see the word ordinal in C#, it means the comparison is based on the raw value of the characters in the string, not on any language rules. In other words, it is a comparison of the underlying text, where a is not treated as the same as A unless you explicitly use an ignore case option. This is why ordinal comparisons are a good fit for things like commands, keys, IDs, and file extensions. You want the comparison to behave the same way on every machine, regardless of culture, because those values are tokens in your system, not user-facing words.
Ordinal versus invariant
It is also important to understand the separation between ordinal and invariant, because they solve different problems. Ordinal is about how strings are compared, so it controls equality checks and ordering by using the raw character values. Invariant is about culture, so it controls how values are formatted and how case conversion behaves when you do not want the result to depend on the current culture. In practice, you will often use them together, therefore invariant formatting for values you store or log, and ordinal or ordinal ignore case for comparisons of keys and commands where you require predictable matching.
using System.Globalization;
string rawCommand = " START ";
// Invariant is about producing consistent results that do not depend on culture.
string normalisedCommand = rawCommand.Trim().ToUpperInvariant();
bool isStart = string.Equals(normalisedCommand, "START", StringComparison.Ordinal);
Console.WriteLine(isStart);
// Output:
// TrueThe example above uses both invariant and ordinal rules so the behaviour is predictable. Firstly, Trim removes the surrounding whitespace, and ToUpperInvariant normalises the casing using culture-agnostic rules, so the result does not change depending on the machine culture. Then string.Equals uses StringComparison.Ordinal, which performs a straight binary comparison of the characters, meaning the command either matches or it does not, with no language rules involved. This is the kind of approach you want for commands, keys, and identifiers.
C# strings and immutability
As mentioned in What are C# Strings?, C# strings are immutable, which means any operation that appears to modify a string actually returns a new instance of that string instead of changing the original string value.
string message = "hello, world!";
string uppercaseMessage = message.ToUpper();
Console.WriteLine(message);
Console.WriteLine(uppercaseMessage);
Console.WriteLine(message);
// Output:
// hello, world!
// HELLO, WORLD!
// hello, world!In the example above, the message.ToUpper() method does not affect the message string variable at all, as it is immutable. You can see this in the second call to the Console.WriteLine(message) method, the output is unaffected by the uppercase method. Only the uppercaseMessage variable contains the modified uppercase message.
Immutable string benefits
The main benefit of string immutability is that it makes strings safe to share. Once a string is created, it never changes, so you can pass it between methods, store it in variables, cache it, or use it across threads without being concerned that something else will mutate it later. That behaviour removes an entire category of bugs. The trade-off is that performance can suffer when you treat strings like a mutable buffer. If you keep modifying a string repeatedly, especially inside a loop, you end up creating a lot of temporary string instances, which means more allocations, and more work for the garbage collector. This is why the StringBuilder exists, and why it is usually the better option when you are building up larger strings from many small parts.
Common operations performed on C# strings
The string type has many useful methods built in. Below are some of the common string methods you will use, especially when you are handling user input, parsing configuration values, or building output for logs and APIs.
Trim
The Trim, TrimStart, and TrimEnd methods remove whitespace from a string. This is a common first step when dealing with user input.
string raw = " hello world ";
Console.WriteLine($"'{{raw.Trim()}}'");
Console.WriteLine($"'{{raw.TrimStart()}}'");
Console.WriteLine($"'{{raw.TrimEnd()}}'");
// Output:
// 'hello world'
// 'hello world '
// ' hello world'The example above outlines how the three trimming methods remove whitespace. Trim removes whitespace from both ends of the string. TrimStart only removes whitespace from the beginning, and TrimEnd only removes it from the end.
Outputting the values wrapped in single quotes makes it easier to see what has changed.
Replace
The Replace method returns a new string, where every occurrence of a substring has been replaced. This is useful for simple templating, sanitisation, and normalisation tasks.
string template = "Hello, {name}!";
string message = template.Replace("{name}", "Dan");
Console.WriteLine(message);
// Output:
// Hello, Dan!The example above replaces the {name} placeholder with a real value. Replace does not modify the original string, it returns a new one, which is why the result is assigned to message.
Split
The Split method breaks a string into parts, and we go through the Join method above.
string csv = "apples,bananas,pears";
string[] items = csv.Split(',');
Console.WriteLine(items.Length);
Console.WriteLine(string.Join(" | ", items));
// Output:
// 3
// apples | bananas | pearsThe example above splits a comma-separated list into an array. The first output shows how many items were produced, and the second output joins the array back together using a different delimiter |.
For user input, you will often want to remove empty entries and trim the parts, otherwise you could end up with unexpected edge case errors.
string input = " apples, , bananas , pears ";
string[] split = input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
Console.WriteLine(string.Join(", ", split));
// Output:
// apples, bananas, pearsThe example above handles unstructured input by removing empty values, and trimming whitespace. This avoids cases where you end up with blank items in your array, or array items that contain leading and trailing spaces.
StartsWith and EndsWith
These StartsWith and EndsWith methods are useful for checking for prefixes and suffixes such as file extensions, route matching, or validation. If you need case-insensitive behaviour, you may pass a StringComparison parameter.
string fileName = "report.PDF";
bool isPDF = fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
Console.WriteLine(isPDF);
// Output:
// TrueThe example above checks the file extension using a case-insensitive ordinal comparison. This is a common approach for checking a suffix, where you do not want culture rules involved.
Contains
The Contains method checks whether a substring exists inside a string. You can also input a StringComparison parameter to the method.
string message = "Hello, world!";
Console.WriteLine(message.Contains("world"));
Console.WriteLine(message.Contains("WORLD", StringComparison.OrdinalIgnoreCase));
// Output:
// True
// TrueThe example above shows both a case-sensitive search and a case-insensitive Contains usage. When you input StringComparison.OrdinalIgnoreCase, the result is more suitable for matching tokens and identifiers, where casing should not matter.
Substring
The Substring method extracts part of a string based on an index and length. Validating an index exists first is important, otherwise you will get runtime exceptions when you attempt to access an index that does not exist.
string token = "Bearer MY_BEARER_TOKEN";
string scheme = token.Substring(0, 6);
string value = token.Substring(7);
Console.WriteLine(scheme);
Console.WriteLine(value);
// Output:
// Bearer
// MY_BEARER_TOKENThe example above extracts two parts of a bearer token string. The first call takes the first six characters, and the second call takes everything after the space.
IndexOf and LastIndexOf
The IndexOf and LastIndexOf methods indicate where a substring occurs. They are often used alongside slicing logic, for example extracting an extension, a domain, or a segment of text.
string filePath = @"C:\report.final.pdf";
int lastDot = filePath.LastIndexOf('.');
string extension = lastDot >= 0 ? filePath.Substring(lastDot + 1) : "";
Console.WriteLine(extension);
// Output:
// pdfThe example above finds the last dot in the file name and then takes everything after it to get the file extension. Using the LastIndexOf method is useful here, because filenames can contain multiple dots, and you would need to get the final segment, to be able to extract the file extension.
string.Empty, empty strings, and null checks
In C#, an empty string is a string that contains no characters. You will most commonly see an empty string written as "", but you will also see the string.Empty constant used. Both represent the same empty string.
The difference is not between "" and string.Empty, it is between an empty string and null. A null string means the variable does not reference any string instance at all, whereas an empty string is a real string instance with a length of zero.
string emptyLiteral = "";
string emptyConstant = string.Empty;
Console.WriteLine(emptyLiteral.Length);
Console.WriteLine(emptyConstant.Length);
Console.WriteLine(emptyLiteral == emptyConstant);
// Output:
// 0
// 0
// TrueThe example above shows the string.Empty constant and "" both representing an empty string value. They behave the same way, and both output a length of 0. In most code it comes down to preference, although string.Empty can be slightly clearer when you are returning an empty string from a method. The risk of accidentally typing an incorrect empty string literal is minimal, but it is still a possibility, whereas string.Empty is a constant that cannot be mistyped.
string.IsNullOrEmpty
When you deal with user input, configuration values, or data coming from an API, you often need to handle both null and empty strings. Instead of writing two checks, you can use the string.IsNullOrEmpty method.
string a = null;
string b = "";
string c = "hello";
Console.WriteLine(string.IsNullOrEmpty(a));
Console.WriteLine(string.IsNullOrEmpty(b));
Console.WriteLine(string.IsNullOrEmpty(c));
// Output:
// True
// True
// FalseThe example above shows the functionality of the string.IsNullOrEmpty method. It returns true when the value is null or when it is an empty string, and it returns false when the string contains any characters.
string.IsNullOrWhiteSpace
Sometimes an empty value is not literally empty, and it just contains whitespace characters. For example, a user might submit a form with a value that looks blank but actually contains spaces or tabs. In those cases, string.IsNullOrWhiteSpace can detect those inputs.
string a = null;
string b = "";
string c = " ";
string d = "hello";
Console.WriteLine(string.IsNullOrWhiteSpace(a));
Console.WriteLine(string.IsNullOrWhiteSpace(b));
Console.WriteLine(string.IsNullOrWhiteSpace(c));
Console.WriteLine(string.IsNullOrWhiteSpace(d));
// Output:
// True
// True
// True
// FalseThe example above shows that the string.IsNullOrWhiteSpace method checks more than string.IsNullOrEmpty. It returns true for null, for an empty string, and for strings that contain only whitespace characters. This makes it a safer default for validating input, because it treats " " as effectively empty.
StringBuilder in C#
Because strings are immutable, repeated concatenation inside loops can create a lot of temporary string instances. For small amounts of concatenation it is fine, but if you are building up a large string from many small parts, the StringBuilder was introduced as a more efficient alternative.
A classic example where using a StringBuilder is beneficial, is generating a large report, CSV output, or a block of text where you append line by line.
using System.Text;
StringBuilder builder = new StringBuilder();
for (int i = 1; i <= 5; i++)
{
builder.Append("Line ");
builder.Append(i);
builder.AppendLine();
}
string result = builder.ToString();
Console.WriteLine(result);
// Output:
// Line 1
// Line 2
// Line 3
// Line 4
// Line 5The example above appends multiple values into the same StringBuilder buffer, without creating a new string on every loop iteration. Each Append adds more content to the StringBuilder, and the final ToString produces the final string at the end. This approach is usually faster, and creates far fewer allocations when you are building larger strings.
If you can estimate how large your output will be, you can provide a capacity to the StringBuilder when instantiating it. This can reduce resizing overhead when the builder grows, improving performance.
using System.Text;
StringBuilder builder = new StringBuilder(capacity: 1024);
builder.AppendLine("Header");
builder.AppendLine("Body");
builder.AppendLine("Footer");
Console.WriteLine(builder.ToString());The example above creates a StringBuilder with an initial capacity of 1024 characters. As mentioned above, when setting the capacity, the builder is less likely to resize its internal buffer as it grows, minimising the number of allocations and copying operations.
Summary
In this article we covered what strings are in C#, how they store text using UTF-16, and why that matters when you work with length and indexing. We then went through the different ways to declare strings, including normal literals, verbatim literals, raw string literals, and interpolated strings. After that we looked at formatting, including composite formatting, common format specifiers, alignment, and how to control culture using an IFormatProvider. We also covered common ways to combine strings, how string comparisons work with ==, Equals, ReferenceEquals, and StringComparison, and where culture and case sensitivity can change behaviour. Finally, we outlined why immutability matters, the common string operations you will use day-to-day, how to handle empty strings and null values using string.Empty, string.IsNullOrEmpty, and string.IsNullOrWhiteSpace, and when StringBuilder is worth using when building larger strings.