Mert Özen Further With Every Line
Backend C# Fundamentals — #05

Strings in C#: Interpolation, StringBuilder and the Turkish I Trap

Mert Özen Nov 7, 2026 8 min 2 views
Strings in C#: Interpolation, StringBuilder and the Turkish I Trap

Immutability of strings in C#, interpolation and format specifiers, escape sequences and verbatim strings, commonly used methods, the measured memory difference with StringBuilder, StringComparison and the Turkish I/ı/İ trap.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We convert a book title to upper case, then replace a word in it. At the end we print the title. What do you see on the screen?

string title = "clean code";
title.ToUpper();
title.Replace("clean", "dirty");

Console.WriteLine(title);
clean code

Nothing changed. The program compiled without even a warning, but the title is exactly as it was. The reason is the most important property of C# strings: a string is immutable. ToUpper and Replace don't change the existing string; they create and return a new one. We didn't store that returned value anywhere, so it was lost. The correct version is title = title.ToUpper();. In this post we look at the basic tools for working with strings, the consequences of immutability, StringBuilder, and why culture matters in comparisons.

Strings Can't Be Changed

Once a string is created, its characters never change. Every operation that seems to change it actually produces a new string. Think of text carved in stone: you can't edit it, you can only carve new text into a new stone. When you write title = title.ToUpper();, the old stone stays where it is and the title variable now points to the new one.

This design has nice consequences. When you pass a string to a method, you don't have to worry about that method messing up your string. You can share the same string safely in many places, and several threads reading it at the same time is no problem. The price is performance: when you "change" a string in small pieces over and over, a new string is created at every step. We'll measure that in a later section.

Interpolation and Formatting

The most readable way to put variables into text is string interpolation: you put a $ in front of the string and write the values inside braces.

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string title = "Clean Code";
decimal price = 34.9m;
int stock = 7;

Console.WriteLine("Title: " + title + ", price: " + price);
Console.WriteLine($"Title: {title}, price: {price}");
Console.WriteLine($"Price with two decimals: {price:F2}");
Console.WriteLine($"Stock as three digits: {stock:D3}");
Console.WriteLine($"|{title,-15}|{price,8:F2}|");
Console.WriteLine($"Total: {price * stock:F2}, low stock: {stock < 10}");
Console.WriteLine($"Braces in text: {{ {stock} }}");
Title: Clean Code, price: 34.9
Title: Clean Code, price: 34.9
Price with two decimals: 34.90
Stock as three digits: 007
|Clean Code     |   34.90|
Total: 244.30, low stock: True
Braces in text: { 7 }

The first two lines give the same result, but the second is much easier to read. Inside the braces you can write any expression, not just a variable: {price * stock} or {stock < 10}. After a colon you specify a format: F2 means two decimal places, D3 an integer with at least three digits. The number after a comma is alignment: -15 left-aligns the value in a 15-character field, 8 right-aligns it. Very handy for output that looks like a table in the console. If you want a literal brace in the text, you write it twice.

Because the way numbers are written depends on culture, we fixed the culture at the start of the examples. We'll cover culture and parsing in detail in the next post. Thanks to the interpolated string handlers that came with C# 10, interpolation is also much more efficient than it used to be; the old advice "interpolation is slow, use string.Format" no longer applies.

Escape Sequences and Verbatim Strings

To put a quote, a line break or a tab inside a string, you use escape sequences that start with a backslash. For text full of backslashes, such as Windows file paths, there are verbatim strings:

string quote = "She said \"read this\" and left.";
string lines = "Line 1\nLine 2";
string columns = "Title\tPrice";
string windowsPath = "C:\\books\\new.txt";
string verbatimPath = @"C:\books\new.txt";
string verbatimQuote = @"She said ""read this"" and left.";
string shelf = "A";
string mixed = $@"C:\books\{shelf}\list.txt";

Console.WriteLine(quote);
Console.WriteLine(lines);
Console.WriteLine(columns);
Console.WriteLine(windowsPath == verbatimPath);
Console.WriteLine(verbatimQuote);
Console.WriteLine(mixed);
Console.WriteLine("caf\u00E9");
She said "read this" and left.
Line 1
Line 2
Title	Price
True
She said "read this" and left.
C:\books\A\list.txt
café

The most common ones are \" (quote), \\ (backslash), \n (new line), \t (tab) and the Unicode character code starting with \u. In a verbatim string, marked with @, the backslash is an ordinary character; you add a quote by writing it twice. A verbatim string can also span several lines. You can combine $ and @ to write a string that's both verbatim and interpolated. For quote-heavy text such as JSON, C# 11 brought raw string literals; we'll see them in the C# Versions series.

Commonly Used Methods

The string class has dozens of methods. These are the ones you'll use most in a bookstore application:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

string raw = "  Clean Code: A Handbook of Agile Software Craftsmanship  ";
string title = raw.Trim();

Console.WriteLine(title.Length);
Console.WriteLine(title.ToUpper());
Console.WriteLine(title.Contains("Agile"));
Console.WriteLine(title.StartsWith("Clean"));
Console.WriteLine(title.IndexOf(':'));
Console.WriteLine(title.Substring(0, 10));
Console.WriteLine(title.Replace("Agile", "Modern"));
Console.WriteLine(title[0]);

string tags = "csharp,dotnet,,backend";
string[] parts = tags.Split(',', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(parts.Length);
Console.WriteLine(string.Join(" | ", parts));
Console.WriteLine("42".PadLeft(5, '0'));
Console.WriteLine(string.IsNullOrWhiteSpace("   "));
54
CLEAN CODE: A HANDBOOK OF AGILE SOFTWARE CRAFTSMANSHIP
True
True
10
Clean Code
Clean Code: A Handbook of Modern Software Craftsmanship
C
3
csharp | dotnet | backend
00042
True

Trim removes leading and trailing spaces; apply it first to every input that comes from a user. IndexOf gives the first position of what you're looking for and returns -1 if it doesn't find it. Positions start at 0: title[0] is the first character, and the colon is at position 10. Substring(0, 10) starts at position 0 and takes 10 characters. Split breaks a string into parts; RemoveEmptyEntries drops the empty part between two commas. string.Join does the opposite. And string.IsNullOrWhiteSpace checks in one go whether an input is null, empty or only whitespace. Remember: none of these change title; they all return a new value.

StringBuilder

To see the price of immutability, let's build the text of a report with a thousand books. First with +=, then with StringBuilder. GC.GetAllocatedBytesForCurrentThread() returns the amount of memory allocated so far; by taking the difference we measure how much memory each approach allocates:

using System.Globalization;
using System.Text;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

long start = GC.GetAllocatedBytesForCurrentThread();
string slow = "";
for (int i = 0; i < 1000; i++)
{
    slow += "Book " + i + ";";
}
long concatBytes = GC.GetAllocatedBytesForCurrentThread() - start;

start = GC.GetAllocatedBytesForCurrentThread();
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    builder.Append("Book ").Append(i).Append(';');
}
string fast = builder.ToString();
long builderBytes = GC.GetAllocatedBytesForCurrentThread() - start;

Console.WriteLine(slow == fast);
Console.WriteLine($"Length: {fast.Length}");
Console.WriteLine($"+=            : {concatBytes:N0} bytes");
Console.WriteLine($"StringBuilder : {builderBytes:N0} bytes");
True
Length: 8890
+=            : 8,859,712 bytes
StringBuilder : 50,984 bytes

This output was taken on a Mac with an Apple M5 processor; the exact numbers can shift by a few hundred bytes depending on the runtime version, but the ratio stays the same. Both approaches produced the same 8890-character text. += allocated about 8.9 MB of memory, StringBuilder 51 KB. Here's why: at every step, += creates a new string that copies all the text so far. As the text grows, each copy gets more expensive. StringBuilder keeps a buffer that can grow and produces a single string only at the very end, when you call ToString().

This doesn't mean you should use StringBuilder everywhere. To join two or three pieces, interpolation or + is perfectly fine and more readable. Use StringBuilder when you add an unknown number of pieces inside a loop.

Comparison and StringComparison

In C#, == compares the contents of two strings; it's case-sensitive and matches characters one to one by their codes (an ordinal comparison).

string a = "Clean Code";
string b = "clean code";
string c = "Clean " + "Code".Trim();

Console.WriteLine(a == b);
Console.WriteLine(a == c);
Console.WriteLine(a.Equals(b, StringComparison.OrdinalIgnoreCase));
Console.WriteLine(string.Compare("apple", "Banana", StringComparison.Ordinal));
Console.WriteLine(string.Compare("apple", "Banana", StringComparison.InvariantCulture));
False
True
True
31
-1

c was built by concatenation at run time, yet a == c is true, because what's compared is the content. When you want to ignore case, pass StringComparison.OrdinalIgnoreCase instead of producing two new strings with ToLower(). In sorting the difference is more visible: an ordinal comparison looks at character codes, and because the code of lowercase a (97) is greater than that of uppercase B (66), it puts "apple" after "Banana". A culture-sensitive comparison gives what a human calls alphabetical order, and "apple" comes first. The rule is simple: ordinal for machine-facing text such as file names, keys, commands and codes; culture-sensitive comparison when sorting a list shown to users.

Culture and the Turkish I Trap

Case conversion also uses the culture of the machine it runs on by default. In Turkish the upper case of "i" is "İ" and the lower case of "I" is "ı", so code that works with English text on a server set to Turkish breaks in unexpected ways:

using System.Globalization;

CultureInfo.CurrentCulture = new CultureInfo("tr-TR");

string command = "file";
Console.WriteLine(command.ToUpper());
Console.WriteLine(command.ToUpper() == "FILE");
Console.WriteLine(command.ToUpperInvariant() == "FILE");
Console.WriteLine("TITLE".ToLower());
Console.WriteLine(string.Equals("FILE", "file", StringComparison.CurrentCultureIgnoreCase));
Console.WriteLine(string.Equals("FILE", "file", StringComparison.OrdinalIgnoreCase));
Console.WriteLine("istanbul".ToUpper());
Console.WriteLine("istanbul".ToUpper(CultureInfo.InvariantCulture));
FİLE
False
True
tıtle
False
True
İSTANBUL
ISTANBUL

While preparing this post, when I ran the commonly used methods example without fixing the culture, the ToUpper() output showed "AGİLE" and "CRAFTSMANSHİP"; my computer is set to Turkish. The same problem silently turns into a bug when comparing a command name, an email address or a file extension: in the Turkish culture, "FILE" and "file" aren't equal even when ignoring case. The rule: for machine-facing text use ToUpperInvariant(), ToLowerInvariant() and StringComparison.OrdinalIgnoreCase; for Turkish text shown to users, pass the culture explicitly. "istanbul" should appear to a Turkish user as "İSTANBUL", so both of the last two lines are right in their own context.

Common Pitfalls

Throwing away the returned value. title.Trim(); on its own does nothing. Always assign the result of string methods to a variable.

+= in a loop. After a few hundred pieces the memory and time cost grows quickly. If you're accumulating text in a loop, use StringBuilder.

ToLower() for comparison. a.ToLower() == b.ToLower() creates two unnecessary strings and can give the wrong result in the Turkish culture. Write string.Equals(a, b, StringComparison.OrdinalIgnoreCase).

Assuming the culture. Your code may work with an English culture on your computer and break on a server set to Turkish, or the other way around. For every culture-dependent operation (case, sorting, number formats), state explicitly which culture you want.

A Small Experiment

★☆☆ Fix the Guess First code; you should see "DIRTY CODE" on the screen. Careful: after converting to upper case, "clean" in Replace no longer matches; get the order of the operations right. Use ToUpperInvariant() for the upper-case conversion; we saw why at the end of the post.

★★☆ Write code that produces the initials (RCM) from an author name such as "Robert C. Martin", using Split and a loop. Try it with a name that has extra spaces too.

★★★ Put book titles into an aligned table in the form |Title | Price| using StringBuilder. Then add CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); at the start of the program and see what changes in the price column.

We have strings and numbers in hand, but everything that comes from a user is a string first, and you have to turn it into a number. In the next post we look at type conversions: implicit and explicit conversion, Parse and TryParse, Convert, a first look at boxing, and the traps of culture-dependent parsing.