Last Updated: May 22, 2026
Most real work with strings is not about typing characters into a literal, it's about reading, searching, slicing, and reshaping text that came from somewhere else. A customer typed an email with extra spaces, a product name needs the category stripped off, a CSV row needs to be split into fields, a coupon code needs to be checked for a prefix. The string type has a long list of instance methods and a few static helpers that cover these jobs. This chapter walks through the ones used most often, grouped by category, with e-commerce examples for each.
Every method in this lesson that "modifies" a string actually returns a brand new string. The original string is never touched, because strings in C# are immutable. "hello".Replace('h', 'H') does not change "hello"; it builds a new string "Hello" and hands it back. If you ignore the return value, the work is wasted. The _String Immutability & Interning_ lesson covers why strings are designed this way and what that means for memory; for now, the rule to remember is: assign the result.
The first Trim() produced a trimmed string and threw it away. The second one captured the result, which is what you almost always want.
With that out of the way, here's a quick map of the categories before we go deep on each.
The diagram shows where we're going. Searching answers "is this in the string and where?" Extracting pulls out a piece. Modifying builds a reshaped copy. Splitting and joining move between a single string and an array of strings. Helpers cover the small cases that come up everywhere.
The first family of methods answers questions about whether some text is in a string, and if so, where. Five methods cover most of what you need.
ContainsContains returns true if the argument appears anywhere in the string, false otherwise. It's the simplest of the five, useful when only presence matters, not position.
The third call returns False because the default comparison is case-sensitive. There's an overload that takes a StringComparison value to control case and culture, and we'll touch on it briefly later in this lesson, but the full rules live in the _String Comparison_ lesson.
StartsWith and EndsWithStartsWith and EndsWith check the prefix or suffix of a string. They're often the right answer when you'd otherwise be tempted to write s.Substring(0, n) == "...", which is uglier and allocates a new string for nothing.
Both methods also have StringComparison overloads, same caveat as Contains.
IndexOf and LastIndexOfWhen you need to know not just whether a substring is there, but where, use IndexOf. It returns the zero-based index of the first occurrence, or -1 if the substring is not found.
LastIndexOf does the same thing but scans from the end. That's the right pick when you want the deepest segment of a path or the final separator in something.
Both methods take either a char or a string. The char overload is slightly faster because it doesn't need to compare a multi-character pattern, but the difference rarely matters unless you're scanning huge strings in a tight loop.
Contains, StartsWith, EndsWith, IndexOf, and LastIndexOf are all O(n) in the length of the string being searched (roughly, the runtime uses faster algorithms in some cases, but worst case is linear). They don't allocate. The -1 return from IndexOf is the standard "not found" sentinel; check for it before passing the result to Substring.
Once you know where something is, you usually want to grab a piece of the string around it. Two methods and one indexer syntax handle this.
Substring(start) and Substring(start, length)Substring(start) returns everything from start to the end of the string. Substring(start, length) returns length characters starting at start. Both build a new string.
A small flow diagram of what Substring does internally:
The runtime reads four characters starting at index 0 and copies them into a new string object. The source string is untouched.
Two ways to go wrong with Substring. First, if start is past the end, you get an ArgumentOutOfRangeException. Second, if start + length runs past the end, same exception. Guard against this when you compute the arguments from IndexOf:
s[start..end]C# 8 added the range operator .. for strings, the same syntax you've seen for arrays. s[a..b] returns a substring containing characters from index a up to (but not including) index b. Combined with the from-end operator ^, it covers most slicing needs in a way that reads more naturally than Substring.
The half-open behavior is the same as with arrays: the start is inclusive, the end is exclusive. productName[..4] is characters at indices 0, 1, 2, 3. Skipping either side defaults to the start or end of the string. s[..] is a full copy.
s[a..b] translates to s.Substring(a, b - a). They produce the same result; the range syntax is more readable for simple slices.
Both Substring and the range indexer allocate a new string and copy the characters. To look at a slice without keeping it, ReadOnlySpan<char> provides a view of part of a string without allocating. For everyday code, Substring is fine.
Methods in this group return a new string with some change applied. The original is never modified, because strings are immutable. Most of these accept a char or a string and return a string.
ReplaceReplace swaps every occurrence of one substring or character for another. It comes in two flavors: Replace(char, char) and Replace(string, string).
The example shows method chaining. Each Replace returns a fresh string, and the next method runs on that result. Chaining reads cleanly, but every step allocates.
Each Replace call allocates a new string. Three chained Replace calls allocate three intermediate strings (plus the final one), even though only the last value is visible. For one-off transformations this doesn't matter. Inside a hot loop, switch to StringBuilder.
Insert and RemoveInsert(index, text) returns a new string with text spliced in at the given position. Remove(start) removes everything from start to the end; Remove(start, count) removes count characters starting at start.
(Insert indices count against the string that exists at the moment of the call, which is why the second and third positions look off if you only think about the original. The example above shows the mechanic; for real phone formatting, regex or a dedicated formatter is the better tool.)
Trim, TrimStart, TrimEndUser input is usually messy: extra spaces at the start or end, an accidental tab, a stray newline. Trim() returns a new string with whitespace stripped from both ends. TrimStart() and TrimEnd() strip from one side only.
The no-argument form strips whitespace (' ', '\t', '\n', '\r', and a few other Unicode whitespace characters). The overload that takes a params char[] strips any of those specific characters from the ends. The second example uses Trim('*') to strip asterisks from a banner.
PadLeft and PadRightThese pad a string out to a target width by adding characters on the left or the right. The default pad character is space; pass a char as the second argument to use something else. Useful for aligning columns in a printed report.
PadRight(15) adds spaces to the right of the name until the total length is 15 characters. PadLeft(8) right-aligns the price in an 8-character field. If the string is already at or above the target width, padding does nothing.
A quick reference table for the modifying methods:
| Method | What it does | Returns |
|---|---|---|
Replace(old, new) | Swap every occurrence of old with new | New string |
Insert(i, text) | Splice text in at index i | New string |
Remove(start) | Drop everything from start onward | New string |
Remove(start, count) | Drop count chars starting at start | New string |
Trim() | Strip whitespace from both ends | New string |
TrimStart() / TrimEnd() | Strip whitespace from one end | New string |
PadLeft(n) / PadRight(n) | Pad to width n (default space) | New string |
Four methods cover case conversion. ToUpper() and ToLower() use the current culture for casing rules. ToUpperInvariant() and ToLowerInvariant() use a fixed, culture-independent set of rules.
The short version of when to use which: for anything where the result will be compared, stored, or used as a key (like normalizing an email address for lookup), prefer the invariant variants. The culture-aware versions can produce surprising results in certain locales (the classic example is the Turkish dotted/dotless i). For now, default to ToLowerInvariant() when normalizing identifiers.
Each case conversion allocates a new string. There's no way around that for the standard methods, since the source string is immutable. For case-normalizing inside a comparison, string.Equals(a, b, StringComparison.OrdinalIgnoreCase) avoids the allocation entirely.
These move between a single string and an array of strings. They're the core operations for any code that parses a CSV row, a category path, a list of tags, or anything else with separators.
SplitSplit breaks a string into pieces around a separator. The most common overloads take a char or a string[].
A string separator is useful when the divider is more than one character:
Splitting around " || " (with the spaces) keeps the tag values clean. Splitting around just "||" leaves leading and trailing spaces on each piece, requiring a separate trim.
StringSplitOptions.RemoveEmptyEntriesConsecutive separators or trailing separators produce empty strings in the result. The RemoveEmptyEntries option drops them.
StringSplitOptions.TrimEntries (added in .NET 5) also exists and trims whitespace from each piece. Combine them with | to do both at once: messy.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).
Split allocates an array plus a new string for each piece. For a row with 10 columns that's 11 allocations. In a parsing loop over a large file, this adds up; for occasional parsing in business logic, it's fine.
string.Joinstring.Join is the inverse of Split. It takes a separator and a collection of strings and stitches them together. It's a static method, called on the string type, not on an instance.
The second example uses the params overload, where the items are passed as separate arguments. Both forms produce a single string with the separator inserted between every pair of items, but never at the start or end.
Building a CSV row from a cart with multiple lines:
string.Concatstring.Concat is Join with no separator. It's another static helper that takes any number of strings (or any collection) and glues them together. In most code you'd use string interpolation or the + operator, but Concat shows up when you have an existing collection and want a single string out of it.
Use Concat when you have a small fixed number of strings and want to combine them without the overhead of an interpolation or format string. For three or more concatenations in a row, StringBuilder wins on performance.
Two static methods on the string type are used so often they deserve their own section.
string.IsNullOrEmptystring.IsNullOrEmpty(s) returns true if s is either null or the empty string "". It's the safe one-shot check before you treat a string as having content.
The reason this method exists at all is that without it you'd write if (s == null || s == "") everywhere, and it's easy to forget the null half. IsNullOrEmpty packages both checks behind one call.
string.IsNullOrWhiteSpacestring.IsNullOrWhiteSpace(s) is stricter. It returns true if s is null, empty, or contains only whitespace characters. Use this when "the user typed nothing useful" matters, not just "the string is empty."
For user-facing input validation (a customer name, a shipping address line, a search box), IsNullOrWhiteSpace is almost always the right pick. A name field with only spaces is not a real name.
string and char[]Two helpers move between a string and an array of characters. They're occasional, not daily, but they come up when you need to manipulate characters one at a time or reverse a string.
ToCharArraystring.ToCharArray() returns a new char[] holding every character of the string in order.
The array is fully detached from the string, so writes to the array don't change the string (which couldn't change anyway, since it's immutable).
new string(char[])The string constructor that takes a char[] is the inverse. Build or modify a character array, then wrap it in a string. The most common use case is reversing a string:
Array.Reverse flips the array in place, and new string(chars) constructs a fresh string from it. (For ASCII text this gives the expected reversed output. Strings with multi-byte characters need more care, which the regex and culture chapters touch on.)
ToCharArray allocates a new char[] and copies every character. new string(char[]) allocates a new string and copies the characters back. For a one-off reversal this is fine. For repeated character-level work in a loop, StringBuilder and Span<char> are better fits.
Every method that "modifies" a string returns a new string. The original is untouched. The _String Immutability & Interning_ lesson explains why C# was designed this way (it makes strings safe to share across threads, predictable to hash, and easy to reason about) and the _StringBuilder_ lesson introduces StringBuilder, the mutable counterpart for building a string piece by piece without allocating on every step.
The reference table below summarizes the methods covered in this lesson.
| Method | Category | What it does | Returns |
|---|---|---|---|
Contains(value) | Searching | Is value anywhere in the string? | bool |
StartsWith(value) / EndsWith(value) | Searching | Prefix or suffix check | bool |
IndexOf(value) | Searching | First index, or -1 | int |
LastIndexOf(value) | Searching | Last index, or -1 | int |
Substring(start) / Substring(start, length) | Extracting | Pull out a piece | string |
s[a..b] | Extracting | Range slice (C# 8+) | string |
Replace(old, new) | Modifying | Swap occurrences | string |
Insert(i, text) | Modifying | Splice text in | string |
Remove(start) / Remove(start, count) | Modifying | Drop a chunk | string |
Trim / TrimStart / TrimEnd | Modifying | Strip ends | string |
PadLeft(n) / PadRight(n) | Modifying | Pad to width | string |
ToUpper / ToLower | Case | Culture-aware case | string |
ToUpperInvariant / ToLowerInvariant | Case | Culture-independent case | string |
Split(separator) | Splitting | Break into pieces | string[] |
string.Join(sep, parts) | Joining | Stitch pieces together | string |
string.Concat(parts) | Joining | Join with no separator | string |
string.IsNullOrEmpty(s) | Helper | Null or ""? | bool |
string.IsNullOrWhiteSpace(s) | Helper | Null, empty, or whitespace only? | bool |
ToCharArray() | Convert | String to char[] | char[] |
new string(char[]) | Convert | char[] to string | string |