Every application works with text — and strings are how .NET represents it.
Imagine you're building a login form. The user types a username, an email address, maybe a password. Behind every input box is a string — a sequence of characters that travels from the screen into your application, gets validated, stored, searched, and displayed back.
In C#, string is one of the most used types. It looks simple, but it hides a lot of important behaviour. Understanding how strings work — and how to avoid the most common performance traps — is one of the most useful skills you can develop early.
In this lesson, you'll learn what strings really are, why they're immutable, how to manipulate them safely, and when to use StringBuilder or modern span-based APIs.
A string is a sequence of characters. It can be a single word, a sentence, a JSON payload, a file path — anything made of text.
string in C# is an alias for System.String, a reference type that represents a sequence of UTF-16 code units. It is immutable, which means once a string is created, its contents can never change.
Some important facts:
[index], starting at 0.string.Length gives the number of characters.string.Empty or "".null means no string object exists.Computers fundamentally work with numbers. But users, files, networks, and APIs work with text. Somewhere there must be a bridge between raw numeric data and meaningful human-readable information.
Text is also tricky:
The .NET framework provides a rich string type, a mutable builder (StringBuilder), and modern low-allocation APIs (ReadOnlySpan<char>, SearchValues<char>) to handle all of these concerns.
The most important design decision is immutability — strings cannot change after creation. This gives us thread safety, simpler reasoning, and the ability to share string data without fear of accidental mutation.
Here's how text flows through a typical .NET application:
At the center of all this is the string object. Understanding its behaviour is essential to writing correct and performant text-processing code.
Let's trace what happens when you create and modify strings.
string greeting = "Hello";
The runtime allocates a block of memory on the managed heap to hold the characters H, e, l, l, o. The variable greeting holds a reference to that object.
string name = greeting + " Buddy";
Because strings are immutable, this does not change the original "Hello". Instead, the compiler calls String.Concat, which allocates a new string that contains "Hello Buddy". The original string remains unchanged in memory.
greeting = greeting + " Buddy";
Now the variable greeting points to the new string. The old "Hello" string may become garbage and eventually be collected by the Garbage Collector.
var builder = new StringBuilder();
for (int i = 0; i < 10; i++)
{
builder.Append(i);
}
string result = builder.ToString();
StringBuilder maintains an internal mutable buffer. Each Append writes into that buffer without creating a new string until you call ToString(). This is dramatically more efficient for loops.
In memory, a string is stored as UTF-16 code units. When writing to a file or sending over the network, you usually convert to UTF-8 using Encoding.UTF8.GetBytes(...). The reverse process uses Encoding.UTF8.GetString(...). This encoding step is where many real-world text bugs appear.
using System.Text;
string firstName = "Ada";
string lastName = "Lovelace";
// String interpolation (modern C#)
string fullName = $"{firstName} {lastName}";
Console.WriteLine(fullName); // Ada Lovelace
Console.WriteLine(fullName.Length); // 12
Console.WriteLine(fullName.ToUpperInvariant()); // ADA LOVELACE
// Mutable building with StringBuilder
var builder = new StringBuilder();
for (int i = 0; i < 5; i++)
{
builder.Append(i);
}
string result = builder.ToString();
Console.WriteLine(result); // 01234
Code → Meaning → Result
$"{firstName} {lastName}" — creates a new string using interpolation.Length — returns the number of characters.ToUpperInvariant() — returns an uppercase copy; the original is unchanged.StringBuilder.Append — mutates internal buffer, then ToString() creates one final string.Imagine an e-commerce system generating an order confirmation message. You need to combine a customer name, order ID, and total into a human-readable email:
string customer = "Grace";
int orderId = 1024;
decimal total = 249.99m;
string message = $"""
Dear {customer},
Your order {orderId} has been confirmed.
Total: {total:C}
""";
Console.WriteLine(message);
This uses a raw string literal (C# 11+), which lets you write multi-line text without escaping quotes or newline characters. Interpolation with {total:C} formats the decimal as currency using the current culture.
If this message were built by concatenating many pieces inside a loop, using string += would allocate a new string on every iteration. Instead, a production system would likely use StringBuilder for the loop, then interpolation for the final template.
string — like a fixed sign. Once the letters are printed, you can't erase or change them. To make a different sign, you must create a completely new sign and throw the old one away.
StringBuilder — like a whiteboard. You can write, erase, and append as much as you like. Only when you're done do you take a photo (call ToString()) to make a fixed version.
ReadOnlySpan<char> — like a finger pointing at a portion of the sign. It doesn't copy anything; it just allows you to inspect a region of text efficiently.
What actually happens inside the .NET runtime when you work with strings?
A System.String object contains:
char array (UTF-16)string.IsInterned(value)char[] under the hood)ToString() creates one final immutable stringReadOnlySpan<char> — zero-allocation views over stringsSearchValues<char> — precompiled search sets for fast scanningstring.Create — build a string directly without intermediate allocationsC# 14 and .NET 10 do not change the fundamental string model. The underlying object is still an immutable UTF-16 sequence. However, modern C# (11+) introduced raw string literals, UTF-8 string literals, and performance-oriented APIs like spans continue to be the recommended way to handle high-performance text processing.
string vs StringBuilderMany beginners assume string can be modified because code like name += "!" appears to change the variable. It doesn't — it creates a new string and reassigns the reference.
== vs .Equals()For most reference types, == compares references. But string overloads == to compare values. So both a == b and a.Equals(b) return true if the content is identical, even if the objects are different references.
string a = "test";
string b = "te" + "st";
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
null vs empty vs whitespaceThese are different states:
null — no string object exists at all."" or string.Empty — a valid string with zero characters." " — a string that contains whitespace characters.string? nullable = null;
string empty = "";
string whitespace = " ";
Console.WriteLine(string.IsNullOrEmpty(nullable)); // True
Console.WriteLine(string.IsNullOrEmpty(empty)); // True
Console.WriteLine(string.IsNullOrWhiteSpace(whitespace)); // True
Console.WriteLine(string.IsNullOrEmpty(whitespace)); // False
char vs stringA char uses single quotes and represents one UTF-16 code unit. A string uses double quotes and represents a sequence.
char letter = 'A'; // one character
string text = "A"; // a sequence of one character
+= inside a loopWrong:
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString(); // new string allocated every iteration!
}
Correct:
var builder = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
builder.Append(i);
}
string result = builder.ToString();
This can be orders of magnitude faster.
Using ToLower() or ToUpper() for internal identifiers can create culture-specific surprises (e.g., Turkish İ problem).
Use ToLowerInvariant(), ToUpperInvariant(), or specify StringComparison.Ordinal for identifiers and keys. Use culture-aware methods only for user-facing text.
null before calling methods Calling .Length or .ToUpper() on a null string throws NullReferenceException.
Check with string.IsNullOrEmpty or string.IsNullOrWhiteSpace first, or use null-conditional access name?.Length.
Calling string.Contains, Split, or repeated IndexOf in a hot loop can allocate unnecessarily.
In .NET 8+, use SearchValues<char> and ReadOnlySpan<char> for zero-allocation parsing and scanning.
using System.Buffers;
ReadOnlySpan<char> text = "Hello, World!".AsSpan();
SearchValues<char> vowels = SearchValues.Create("aeiouAEIOU");
int count = 0;
foreach (var ch in text)
{
if (vowels.Contains(ch)) count++;
}
Console.WriteLine(count); // 3
string when:StringBuilder when:ReadOnlySpan<char> / SearchValues when:byte[] or Stream firstchar[] may be simplerstring = an immutable, fixed sequenceStringBuilder = a mutable text builderReadOnlySpan<char> = a zero-copy view over textStringBuilder for loopsstring is immutable — any modification creates a new object.StringBuilder for building text in loops or when many concatenations are needed.$"...") is the modern, readable way to format strings."""...""") simplify multi-line text and JSON.SearchValues for high-performance, allocation-free text scanning in .NET 8+.You've seen how strings work, why immutability matters, and how to choose the right text tool for the job. Let's see if you can apply this knowledge.
1. You need to build a large CSV file by appending 100,000 rows inside a loop. Which type should you use for the best performance?
Correct: B
Why B is correct: StringBuilder maintains a mutable internal buffer. Each Append writes into that buffer without allocating a new string. Only ToString() at the end creates one final string. This is dramatically more efficient than repeated string concatenation in a loop.
Why A is incorrect: string += in a loop creates a new string on every iteration because strings are immutable. This leads to O(n²) time and excessive garbage collection.
Why C is incorrect: char[] is a fixed-size array. You would need to manage capacity and resizing manually, which is exactly what StringBuilder already does for you.
Why D is incorrect: ReadOnlySpan<char> is a view over existing text, not a builder. It cannot efficiently accumulate new characters into a growing string.
Reinforcement: Whenever you need to construct a large string from many pieces, especially inside a loop, StringBuilder is your go-to mutable builder.
2. Why does immutability make strings safer and easier to use in .NET?
Correct: B
Why B is correct: Immutability means once a string is created, its contents cannot change. Therefore multiple threads can safely share the same string without locks. The runtime can also cache hash codes and intern literal strings for efficiency, knowing they will never mutate.
Why A is incorrect: Immutability does not prevent all memory leaks. If you keep references to strings unnecessarily, they will still occupy memory.
Why C is incorrect: Immutability often makes operations slower if many modifications are needed, because each modification creates a new string. That's why StringBuilder exists.
Why D is incorrect: Strings are managed objects and are garbage collected when no longer referenced. Immutability does not exempt them from GC.
Reinforcement: Thread safety, hash code caching, and string interning are three major benefits that flow directly from immutability.
3. What does the following code print?
string a = "hello";
string b = a;
b = b + " world";
Console.WriteLine(a);
Correct: A
Why A is correct: Strings are immutable. b = b + " world" creates a new string and assigns it to b. The original string "hello" remains unchanged, so a still references "hello".
Why B is incorrect: a was never modified. Only b was reassigned to a new string.
Why C is incorrect: The concatenation added " world", not removed "hello".
Why D is incorrect: This is perfectly valid C# and will not throw.
Reinforcement: When you assign a string to another variable, both initially point to the same immutable object. Modifying one via reassignment does not affect the other.
4. A method receives a string parameter that may be null. Which check correctly handles both null and strings containing only spaces?
Correct: C
Why C is correct: string.IsNullOrWhiteSpace returns true if the string is null, empty, or consists only of white-space characters. It is the safest single check for "no meaningful text."
Why A is incorrect: This only checks for null. It misses empty strings and whitespace-only strings.
Why B is incorrect: Calling .Length on a null string throws NullReferenceException. This check is incomplete and unsafe.
Why D is incorrect: This only checks for an empty string, not null or whitespace.
Reinforcement: Use IsNullOrWhiteSpace for most user-input validation. Use IsNullOrEmpty only when whitespace is considered valid data.
5. In .NET 8+, which API would you use to efficiently count occurrences of multiple characters (e.g., vowels) in a large string without allocating intermediate strings?
Correct: C
Why C is correct: SearchValues<char> is designed for fast, allocation-free scanning of text for a set of characters. Combined with ReadOnlySpan<char>, you can iterate without creating substrings or intermediate strings.
Why A is incorrect: Split creates multiple string allocations and is not efficient for counting characters.
Why B is incorrect: Repeated Contains calls allocate or scan inefficiently; they are not optimized for set-based character searches.
Why D is incorrect: StringBuilder.Replace is for replacing text and will allocate even if used for counting. It is not appropriate here.
Reinforcement: For hot-path text processing in modern .NET, prefer SearchValues, ReadOnlySpan<char>, and similar low-allocation APIs to keep performance predictable.
You now have a clear mental model of strings and text in .NET — from immutable strings to high-performance span-based scanning!
dotnetmadeeasy.com — Learn C# and .NET, the right way.