C# Strings — Turkish 'i' Bug Breaks Logins
In Turkish culture, 'I' and 'i' are separate letters — default comparison fails logins.
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
- A string is an immutable sequence of characters, implemented as System.String
- Immutability means every operation that looks like a change creates a new string
- Use string interpolation ($"") for readability — avoid raw concatenation
- StringBuilder is the performance tool for building strings in loops
- String comparison must use StringComparison enum to avoid culture-sensitive bugs
- Biggest mistake: using + inside loops creates O(n²) allocations
Imagine a string of beads on a necklace — each bead is a single letter or character, and the whole necklace is your string. In C#, a string is exactly that: a sequence of characters (letters, numbers, symbols, spaces) treated as one single unit. When your app shows the message 'Welcome, Sarah!' on screen, that entire sentence is a string. Strings are literally everywhere — usernames, error messages, URLs, file paths — if it's text, it's a string.
Every application that has ever existed talks to humans using text. Your banking app says 'Transfer successful.' Your favourite game says 'Player 1 wins.' Your email client says 'No messages found.' All of that is text, and text in C# lives inside strings. Before you can build anything meaningful, you need to understand how C# handles text — and it turns out C# has some very specific and powerful opinions about it.
The problem strings solve is simple: computers only understand numbers. Underneath the hood, the letter 'A' is just the number 65, and 'B' is 66. Strings give you a friendly, human-readable wrapper around all those numbers so you can write code that works with words and sentences naturally, without worrying about the raw numeric values underneath.
By the end of this article you'll know how to create strings, combine them, search inside them, change their content, compare them correctly, and avoid the classic traps that trip up beginners and even some experienced developers. You'll also walk away with a solid mental model of why strings in C# behave the way they do — and that 'why' is what separates developers who just copy code from developers who actually understand it.
What a String Actually Is in C# — and Why It's Immutable
In C#, a string is an object of the built-in System.String class. When you write string with a lowercase 's', it's just a convenient alias — C# quietly replaces it with System.String behind the scenes. Either spelling works; most C# developers use the lowercase version by convention.
Here's the most important thing to understand about strings right away: they are immutable. Immutable means once a string is created, it cannot be changed. Ever. If you think you're changing a string, you're actually creating a brand new string and throwing away the old one.
Think of it like a sticky note. Once you write on it, you can't erase and rewrite it — you have to throw it away and grab a fresh sticky note. This design choice makes strings safe to share across your program without worrying that one piece of code secretly modifies text that another piece depends on.
This immutability has a very real performance consequence we'll revisit in the gotchas section — it's one of the most common C# interview topics.
ToUpper(), Replace(), Trim() — actually returns a NEW string. Your original string is untouched. If you don't capture the return value, the change is lost forever. Always do: string result = original.ToUpper(); not just original.ToUpper();Trim().ToLower() creates two new strings but no garbage if the original is large.Creating and Combining Strings — Concatenation, Interpolation and Verbatim Strings
Once you have strings, you immediately need to combine them. Imagine building a personalised message like 'Good morning, Alice! You have 3 new notifications.' — that sentence is assembled from separate pieces of data. C# gives you several ways to do this, and each has its place.
The oldest way is concatenation using the + operator. It works, but it gets messy fast when you have more than two or three pieces.
String interpolation (using the $ prefix) is the modern, readable approach. You embed expressions directly inside curly braces {} inside the string. It reads almost like plain English and is the recommended approach in modern C# code.
Format strings using string. are older but still appear heavily in legacy codebases, so you need to recognise them.Format()
Verbatim strings (using the @ prefix) let you write strings exactly as they appear — including backslashes and multiple lines — without needing escape characters. They're invaluable for file paths and multi-line text.
Understanding which tool to reach for keeps your code readable and your colleagues happy.
Essential String Methods — Searching, Slicing and Transforming Text
A string sitting still isn't very useful. Real applications constantly need to inspect and reshape text: check if an email address contains '@', extract a username from a full name, remove accidental spaces from user input, replace offensive words, or check if a password meets minimum length requirements.
C# strings come loaded with built-in methods that handle all of these scenarios. You don't need to write complex loops — the heavy lifting is already done for you.
The most important methods to master are: Length (how many characters?), ToUpper() / ToLower() (change case), Trim() (remove whitespace from edges), Contains() (does this text exist inside?), StartsWith() / EndsWith() (checks at boundaries), IndexOf() (where does this text start?), Substring() (cut out a piece), Replace() (swap text for other text), and Split() (break a string into an array of pieces).
These ten methods will handle about 90% of the string manipulation you'll ever need in day-to-day development.
IndexOf() returns -1 when the text isn't found — not zero. Zero means it was found at the very beginning of the string. Always check for -1 before using the result in a Substring() call, or you'll get an ArgumentOutOfRangeException at runtime. Pattern: int pos = text.IndexOf("@"); if (pos >= 0) { / safe to use pos / }Contains(), StartsWith(), EndsWith() use Ordinal comparison by default in .NET 5+ — but in older frameworks they used CurrentCulture. This caused behavior changes on upgrade.Split() with ',' does not trim each element — leading/trailing spaces remain. Use StringSplitOptions.TrimEntries in .NET 5+.Comparing Strings Correctly and Building Strings Efficiently with StringBuilder
String comparison sounds trivial — just use ==, right? In C# it's mostly safe for simple cases, but the moment you deal with user input, data from APIs, or mixed-case text, naive comparison breaks down. 'Alice' and 'alice' are the same username but == says they're different.
The correct tool is string. with a Equals()StringComparison option, or string.. These let you explicitly say whether case matters and whether to use the current culture's rules or a stable, predictable ordinal comparison.Compare()
Now for performance: remember how strings are immutable? Every time you use + to build a string inside a loop, you're creating and discarding a new string object on every single iteration. If you're building a 10,000-row report by appending line by line, that's 10,000 string objects created and thrown away. That's a serious memory and speed problem.
StringBuilder from System.Text solves this exactly. It's like a mutable notepad — you keep writing on the same object, and only convert it to a final string at the very end. For any string built in a loop, always use StringBuilder.
ToString(). Use string for simple, fixed text. Use StringBuilder when building text across multiple operations, especially inside loops.String Pooling, Interning, and Performance Trade-offs
C# has an internal string pool — a hash table of unique string literals. When you write string hello = "Hello"; and later string greet = "Hello";, both variables point to the same object in memory. The runtime interning reduces memory for duplicate literals.
But there's a gotcha: strings created at runtime (e.g., from JSON, user input, or concatenation) are NOT automatically interned. They live on the heap as separate objects, even if the content is identical. You can explicitly intern them with string., but that's rarely worth it — interning adds lifetime to the pool and can increase memory if you intern many unique strings.Intern()
Another key performance detail: string.Empty vs "". They are equivalent at runtime (both point to the same interned empty string), but using string.Empty is more readable and signals intent. However, in hot loops, string.Empty is a static field access, while "" is a literal — the difference is negligible, but the compiler optimizes both.
Finally, consider StringComparer for dictionary keys: if you have a Dictionary<string, int> and need case-insensitive keys, pass StringComparer.OrdinalIgnoreCase to the constructor. This avoids calls to ToLower on every lookup.
Intern(). For most apps, it's not worth it — rely on the built-in literal pooling.Intern() each one as you read.ToLower().string vs. System.String — The Aliasing Trap That Burns Beginners
Every C# dev knows string is an alias for System.String. Few understand why that distinction matters at 3 AM when a legacy codebase throws a NullReferenceException. Here's the deal: string works without using System;. String doesn't. That alone makes string the production-safe choice.
But the real gotcha? string? declares a nullable reference type. String? does the same, but only if you've enabled nullable reference types. Mixed codebases? You'll see String pop up in auto-generated files or old-school BCL examples. Treat them as identical in runtime behavior—they compile to the same IL. The difference is purely syntactic sugar.
When you see System. or String.Copy()System. in a code review, that's fine. But write String.Intern()string in your own code. It's idiomatic, consistent, and signals you understand the type system. One less argument at code review.
string over String in method signatures and local variables. Only use System.String when you need to avoid ambiguity in reflection or fully qualified names in generated code.string for everyday code; never assume String works without using System;. They're the same IL, but string is the idiomatic alias.Quoted, Verbatim, and Raw String Literals — Stop Escaping Like It's 2005
String literals in C# come in three flavors, and picking the wrong one is how you end up with a regex that looks like a cat walked across your keyboard. Quoted literals use backslash escapes: "c:\\Program Files\\App". Verbatim literals (@"...") ignore escapes except for double quotes (""), so paths become readable: @"c:\Program Files\App".
Raw string literals (C# 11+) are the real game changer. They handle multi-line text, embedded quotes, and JSON without screaming. Use three or more double quotes """. The compiler aligns indentation based on the closing delimiter. Perfect for SQL queries, JSON payloads, or any string where backslashes would make you cry.
Never use quoted literals for file paths or Windows directory strings. That's amateur hour. Verbatim strings handle paths cleanly. Raw strings handle everything else. Pick the tool that minimizes character count and maximizes readability.
String Interpolation and Composite Formatting — The Performance Killer Nobody Talks About
Everyone loves string interpolation: $"Hello, {name}!". It's clean, concise, and readable. But every interpolated string compiles to a string. call under the hood—unless you're using C# 10+ with Format()String.Concat optimizations for simple cases. That means boxing, culture-dependent formatting, and allocation overhead.
Composite formatting (string.Format("Hello, {0}!", name)) predates interpolation. It's more verbose, but gives you explicit control over culture and formatting specifiers. When you're logging millions of rows or building report lines in a tight loop, the difference matters.
Here's the rule: use interpolation for UI strings and developer-facing output. Use composite formatting with CultureInfo.InvariantCulture for log messages, data serialization, and any string that crosses machine boundaries. And never, ever use $ in a loop where performance counts—pre-allocate the format template and call string.Format once.
InvariantCulture for logs, serialization, and any cross-boundary output.Substrings in C# — Substring(), AsSpan(), and When the Difference Is 10x Performance
Substrings are everywhere—parsing logs, extracting tokens, slicing user input. The default reflex is Substring(), and it works fine for small tasks. But here's the dirty secret: every call to Substring() allocates a brand new string on the heap. For hot paths—think JSON parsers, CSV processors, or tight loops processing thousands of entries—that allocation tax adds up fast. Enter AsSpan(). It returns a ReadOnlySpan<char> that points into the original string's memory, zero allocation. The performance difference? An order of magnitude faster in benchmarks. No copy, no GC pressure, just a lightweight view. Use Substring() when you actually need a new string (e.g., to pass to legacy APIs), and AsSpan() when you're reading or iterating. The Slice() method on spans replaces index-based manual math with clear, efficient slicing. Stop allocating memory just to read a few characters.
ReadOnlySpan<char> back to string via new string(span) re-allocates. Only do this when you truly need a string object (e.g., dictionary keys).AsSpan() + Slice() over Substring() in hot paths—zero allocations, 10x faster.Accessing Individual Characters — Indexer, Span, and the Allocation Trap
Need a single character from a string? The indexer s[5] returns a char in O(1) time—no allocation, no fuss. So why does this topic deserve a warning? Because the moment you do anything with that character—convert it, pass it to a method expecting string, or concatenate it—you silently trigger allocations. The trap: s[5]. allocates a new string. Building a new string by iterating characters with ToString()+=? That's O(n²) allocations. Use StringBuilder or, better yet, a char[] buffer. For zero-allocation character access, AsSpan() gives you the same indexer but on a ReadOnlySpan<char>, which works with stackalloc buffers and avoids heap allocations entirely. When you need to transform characters (uppercase, filter, replace), operate on spans rather than creating new string intermediate versions. The golden rule: read with the indexer, process with spans, and allocate only when you must persist the result.
s[i].ToString() or char.ToUpper(s[i]) in a loop allocates per iteration. Collecting them into a string via += is quadratic. Use StringBuilder or span-based mutation.ToString() on a single char is not. Read with indexer, process with spans, allocate only when storing.Login Failure Due to Turkish 'i' — Culture-Sensitive Comparison
- Always specify StringComparison explicitly for any comparison involving user input or identifiers.
- Default culture-sensitive comparison is for user-facing text sorting, not for programmatic identifiers.
- Use OrdinalIgnoreCase for usernames, file paths, and any internal tokens.
(int)userInput[0] to see raw char codestring.Equals(a, b, StringComparison.Ordinal)Key takeaways
ToLower()Common mistakes to avoid
5 patternsUsing + concatenation inside a loop
Comparing strings with == when culture matters
Calling Substring without checking IndexOf return
Assuming ToUpper()/ToLower() modifies the original string
ToUpper();Not handling null strings when calling methods
Contains() etc. on a null reference.IsNullOrWhiteSpace() checks. Use null-conditional operator: text?.Length ?? 0. Or use pattern matching: if (text is null) return;Interview Questions on This Topic
Why are strings in C# immutable, and what are the practical implications?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
That's C# Basics. Mark it forged?
8 min read · try the examples if you haven't