The fastest way I spot subtle bugs in a C# codebase is by searching for Contains( in request validation, auth checks, routing, and log parsing. It’s not because string.Contains() is “bad” (it isn’t). It’s because people often assume it behaves like a search box in a browser: case-insensitive, culture-aware in a predictable way, and “close enough” for identifiers.
In reality, string.Contains() is a very small API with a very specific job: tell you whether a sequence of characters appears inside another string. That sounds simple until you mix in case rules, Unicode, culture, null handling, and performance in hot paths.
I’m going to show you how Contains() actually behaves, how I choose the right StringComparison mode, when I switch to IndexOf() (and why), and a few patterns I rely on in modern .NET projects (including span-based checks) to avoid hidden allocations and correctness traps.
What string.Contains() Really Promises
At its core, string.Contains() answers one question: “Does this string contain that substring?” It returns true or false.
Here’s the shape you’ll see most often:
public bool Contains(string value)
A few rules matter in day-to-day work:
- Case sensitivity (default):
Contains(string)performs a case-sensitive search. - Empty substring: If the substring you pass is
"", the answer istruefor any non-null string (including""itself). - Null substring: If you pass
null, you’ll get anArgumentNullException. - Search direction: It conceptually checks from the start of the string to the end.
I like to think of it like scanning a printed page for an exact sequence of letters, without guessing your intent.
A minimal runnable example:
using System;
public class Program
{
public static void Main()
{
string productName = "Cloud Storage Pro";
Console.WriteLine(productName.Contains("Storage")); // True
Console.WriteLine(productName.Contains("storage")); // False (case-sensitive)
Console.WriteLine(productName.Contains("")); // True
try
{
Console.WriteLine(productName.Contains(null));
}
catch (ArgumentNullException ex)
{
Console.WriteLine($"Null throws: {ex.GetType().Name}");
}
}
}
If you stop here, you’ll write correct code sometimes… and incorrect code surprisingly often, because most real problems require a comparison rule.
The API Surface I Actually Use in Real Code
When people say “Contains is tricky,” they’re usually reacting to one of these realities:
1) You often need to control case rules.
2) You sometimes want to search for a single character.
3) In high-throughput code, you want to search without slicing/allocations.
In practice, I reach for three shapes:
text.Contains("needle", StringComparison.Whatever)for substring checks with an explicit comparison rule.text.Contains(‘x‘)when I’m checking a single character.text.AsSpan(...).IndexOf(...)/ span-based searching when I already have a slice.
Contains(char) is underrated
A lot of “over-engineered” checks can be simplified to a character search.
Example: validating that something “looks like” an email before doing real validation.
bool hasAt = email.Contains(‘@‘);
This doesn’t validate an email (and shouldn’t pretend to), but it’s a cheap pre-check that’s hard to mess up.
The StringComparison overload is the one I want in production
If the substring has any semantic meaning (token, key, header, ID, extension, domain, route segment), I want the overload that forces me to choose a comparison mode.
bool hasNotes = slug.Contains("notes", StringComparison.OrdinalIgnoreCase);
Even if the default overload would “work,” being explicit makes your intent portable across teams, runtimes, and future refactors.
Case Rules: I Pick StringComparison First, Not Last
In modern .NET, you can (and usually should) use the overload that takes a StringComparison:
public bool Contains(string value, StringComparison comparisonType)
This is where you stop guessing and start being explicit.
My default recommendations
I use these rules so consistently that code review feels mechanical:
- Identifiers, protocols, file formats, tokens, headers, machine-readable strings:
StringComparison.OrdinalorStringComparison.OrdinalIgnoreCase - User-facing text searches (what a person typed into a UI search field):
StringComparison.CurrentCultureIgnoreCase(sometimesCurrentCultureif case matters) - Cross-user, cross-machine “human-ish” comparisons (rare):
StringComparison.InvariantCultureIgnoreCase(only when you truly need culture-stable linguistic behavior)
If you only remember one thing: security- and protocol-adjacent checks should almost always be ordinal.
A quick demo: case-sensitive vs ordinal ignore case
using System;
public class Program
{
public static void Main()
{
string slug = "ReleaseNotes-2026";
Console.WriteLine(slug.Contains("notes")); // False
Console.WriteLine(slug.Contains("notes", StringComparison.OrdinalIgnoreCase)); // True
}
}
Traditional vs modern: choosing the right comparison mode
Here’s the decision table I wish every project had in its coding standards:
Best choice
—
"Bearer " Ordinal
OrdinalIgnoreCase
CurrentCultureIgnoreCase
InvariantCultureIgnoreCase
Culture gotchas (the ones that bite teams)
If you’re tempted to “just lowercase both strings,” I get it. It looks simple:
// I do NOT recommend this for hot paths or correctness.
if (text.ToLowerInvariant().Contains(term.ToLowerInvariant()))
{
// …
}
Problems with that pattern:
- It allocates new strings.
- It can behave differently than you expect for Unicode edge cases.
- It hides the intended comparison rule.
Using Contains(term, StringComparison.OrdinalIgnoreCase) expresses intent and avoids those extra allocations.
My “machine vs human” mental model
When I’m stuck choosing a comparison mode, I ask a single question:
If two different users with different system locales run this code, should the answer be guaranteed identical?
- If yes, I use
Ordinal/OrdinalIgnoreCase. - If no (because it’s user-facing text search), I use
CurrentCulture....
That one question prevents most “works on my machine” string bugs.
When I Need Positions: IndexOf() Complements Contains()
Contains() tells you whether a match exists. The moment you need more—like the starting index, slicing, highlighting, or counting matches—I switch to IndexOf().
A common workflow:
- Check if the substring exists.
- If it does, find the index.
You can do it in two calls, but when I care about performance, I usually do one IndexOf() call and check for >= 0.
Runnable example that finds the first match and shows a 1-based position for human-readable output:
using System;
public class Program
{
public static void Main()
{
string message = "Payment approved for invoice INV-20491";
string token = "INV-";
int index = message.IndexOf(token, StringComparison.Ordinal);
bool found = index >= 0;
Console.WriteLine($"Found ‘{token}‘: {found}");
if (found)
{
Console.WriteLine($"Starts at character position {index + 1}");
}
}
}
Finding all occurrences (no regex required)
If you’re parsing a line with repeated separators, a loop over IndexOf() is simple and predictable:
using System;
using System.Collections.Generic;
public class Program
{
public static void Main()
{
string logLine = "WARN: timeout; WARN: retry; WARN: fallback";
string marker = "WARN";
List positions = FindAllIndexes(logLine, marker, StringComparison.Ordinal);
Console.WriteLine(string.Join(", ", positions));
}
private static List FindAllIndexes(string text, string value, StringComparison comparison)
{
if (text is null) throw new ArgumentNullException(nameof(text));
if (value is null) throw new ArgumentNullException(nameof(value));
if (value.Length == 0) throw new ArgumentException("value must be non-empty", nameof(value));
var result = new List();
int startIndex = 0;
while (true)
{
int index = text.IndexOf(value, startIndex, comparison);
if (index < 0) break;
result.Add(index);
startIndex = index + value.Length;
}
return result;
}
}
Notice the explicit guard against value.Length == 0. Without it, you’ll write an infinite loop because the match is always “found” at the current position.
LastIndexOf() for “ends near the end” problems
I use LastIndexOf() when I’m checking for markers near the end of a line (suffix tokens, trailing IDs, final delimiter). It can be a clearer expression of intent than scanning from the start.
Example: grabbing the final # fragment from a message.
int hash = message.LastIndexOf(‘#‘);
if (hash >= 0)
{
string fragment = message.Substring(hash + 1);
// …
}
(If you’re doing this in a tight loop, you can do it span-based too; the point here is readability and intent.)
Real-World Patterns Where Contains() Shines (and Where It Fails)
I like Contains() when the question is truly boolean and the rule is clear.
1) Feature flags and configuration strings
Example: checking an environment variable that may contain a comma-separated list.
using System;
public class Program
{
public static void Main()
{
string enabled = Environment.GetEnvironmentVariable("ENABLED_MODULES")
?? "payments,search,reporting";
bool paymentsOn = enabled.Contains("payments", StringComparison.OrdinalIgnoreCase);
Console.WriteLine($"Payments enabled: {paymentsOn}");
}
}
If commas matter (to avoid "pay" matching "payments"), I don’t use Contains() at all—I split, parse, or use a delimiter-aware check.
2) URL and path checks
For URLs, I avoid culture-aware comparisons. A URL is not prose.
bool isApiCall = requestPath.Contains("/api/", StringComparison.OrdinalIgnoreCase);
Better yet, if you’re in ASP.NET Core, route matching should handle most of this. Contains() is a quick check, not a routing system.
3) Log filtering
Logs are great candidates for OrdinalIgnoreCase checks because you want stable behavior regardless of server locale.
if (line.Contains("timeout", StringComparison.OrdinalIgnoreCase))
{
// tag or route this entry
}
Where I avoid Contains()
These are the cases where I actively push back in reviews:
- Authorization checks (example:
if (role.Contains("admin"))) because it’s too easy to match unintended values. - Parsing structured formats (JSON, XML, CSV) because you’ll get false positives and miss escaping rules.
- File extension checks (
.Contains(".png")) becausereport.png.tmpmatches.
If structure matters, use a structural approach.
Safer Alternatives for the “I Was Going to Use Contains()” Moments
A lot of bugs come from using Contains() where you actually need one of these:
StartsWith(...)(prefix semantics)EndsWith(...)(suffix semantics)Equals(...)(exact token semantics)- Tokenization (
Split, parsing, or structured deserialization)
Authorization: substring checks are a footgun
Bad:
// BUG: "superadmin" matches, "not-an-admin" matches, etc.
if (role.Contains("admin", StringComparison.OrdinalIgnoreCase))
{
Allow();
}
Better:
if (string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase))
{
Allow();
}
Even better (common in real systems): roles are a set, not a string. Represent them as such.
// Example shape, not a full auth model
var roles = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"admin",
"billing",
"support"
};
bool isAdmin = roles.Contains(userRole);
Headers: prefer parsing over substring searching
Bad:
// BUG: "BearerXYZ" and "NotBearer " might match
bool hasBearer = authHeader.Contains("Bearer ", StringComparison.Ordinal);
Better:
bool hasBearer = authHeader.StartsWith("Bearer ", StringComparison.Ordinal);
Best (when you need correctness): split once and validate.
if (authHeader.StartsWith("Bearer ", StringComparison.Ordinal))
{
string token = authHeader.Substring("Bearer ".Length);
if (!string.IsNullOrWhiteSpace(token))
{
// validate token
}
}
File extensions: use suffix checks or Path
Bad:
bool isPdf = fileName.Contains(".pdf", StringComparison.OrdinalIgnoreCase);
Better:
bool isPdf = fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
If you’re dealing with actual file paths, Path.GetExtension(fileName) is often the clearest expression of intent.
Unicode Is the Silent Partner in Every String Search
Most Contains() bugs aren’t about ASCII “A vs a.” They’re about the fact that text can be represented in multiple valid ways.
Here are the big Unicode realities that matter when you do substring checks:
1) The same visible text can have different underlying code points
For example, a character with an accent might be represented as:
- a single composed character, or
- a base character + a combining mark
These can look identical on screen and still fail an ordinal substring search.
Practical implication: if you’re building a user-facing search feature and you expect “what the user sees” to match, you may need normalization.
using System.Text;
string normalizedText = text.Normalize(NormalizationForm.FormC);
string normalizedTerm = term.Normalize(NormalizationForm.FormC);
bool hit = normalizedText.Contains(normalizedTerm, StringComparison.CurrentCultureIgnoreCase);
I don’t normalize everywhere. I normalize when the product requirement is “search should feel human,” and I can justify the cost.
2) “Character count” isn’t always “what a user thinks a character is”
IndexOf() returns an index in UTF-16 code units. That’s perfect for slicing strings in .NET, but it’s not the same as grapheme clusters (what users think of as one character, especially with emoji sequences).
Practical implication: Contains() is correct for storage and protocols; for UI caret movement and visual selection, you need text-element aware APIs. That’s not a Contains() problem, but it becomes one when developers use IndexOf() indexes for UI display.
3) Culture-aware comparisons can do “linguistic” matching you didn’t intend
That’s why I draw a hard line:
- Machine tokens: ordinal.
- Human search: culture-aware.
If you violate that line, you get hard-to-reproduce bugs that only show up in certain languages or locales.
Performance Notes: What Matters in Hot Paths
In most apps, Contains() performance is irrelevant. In services that scan big payloads or process many messages per second, it starts to matter.
Here’s how I reason about it:
Contains()is fundamentally a search. It can be O(n*m) in the worst case (n = haystack length, m = needle length).- Doing
ToLower()orToUpper()to force case-insensitivity adds allocations and extra work. - Repeating the same search multiple times (for multiple keywords) can turn into a bottleneck.
Prefer the comparison overload instead of normalization allocations
This is the simplest win:
// Good: expresses intent, avoids extra strings
bool hit = text.Contains(term, StringComparison.OrdinalIgnoreCase);
// Risky: allocates and hides comparison intent
bool hit2 = text.ToUpperInvariant().Contains(term.ToUpperInvariant());
One scan beats two scans
If you already need an index, do IndexOf() once.
int idx = text.IndexOf(term, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
// use idx
}
I treat Contains() as a readability tool. When performance matters or I need more information, I switch to IndexOf().
Span-based checks when you already have slices
Modern .NET gives you ReadOnlySpan so you can work with slices without creating new strings. If you’re already slicing or reading buffers, this avoids extra allocations.
Example: checking only the first N characters of a line:
using System;
public class Program
{
public static void Main()
{
string line = "ERROR: database connection refused";
ReadOnlySpan prefix = line.AsSpan(0, Math.Min(line.Length, 10));
// If your target framework supports span-based searching with comparison options,
// this stays allocation-free.
bool looksLikeError = prefix.IndexOf("ERROR".AsSpan(), StringComparison.Ordinal) >= 0;
Console.WriteLine(looksLikeError);
}
}
If you’re on a target framework that doesn’t support the exact span overload you want, fall back to IndexOf on strings with StringComparison. The key idea is the same: avoid creating temporary strings just to search.
When multiple keywords matter
If you have a fixed set of keywords, calling Contains() repeatedly can be fine for small inputs. For large inputs or many keywords, I consider:
- A single pass with more specialized logic
- A proper parser if the data has structure
- A search algorithm suited to multiple patterns
I don’t reach for fancy algorithms by default. I start by making the comparison rule explicit and removing accidental allocations.
A practical middle ground I use often: short-circuit fast checks first.
bool IsInteresting(string line)
{
// Cheap early-outs
if (line.Length < 5) return false;
if (!line.Contains(‘:‘)) return false;
// Then your meaningful substring checks
return line.Contains("timeout", StringComparison.OrdinalIgnoreCase)
|| line.Contains("retry", StringComparison.OrdinalIgnoreCase)
|| line.Contains("circuit-breaker", StringComparison.OrdinalIgnoreCase);
}
This isn’t “perfect performance engineering,” but it’s the kind of pragmatic shape that holds up in production.
Common Mistakes I See (and How I Fix Them)
These show up constantly, even on experienced teams.
Mistake 1: assuming case-insensitive behavior
Symptom:
if (customerEmail.Contains("@Example.com"))
{
// …
}
Fix:
if (customerEmail.Contains("@example.com", StringComparison.OrdinalIgnoreCase))
{
// …
}
Mistake 2: using Contains() for “word” checks
Contains("cat") matches "educate". If you meant word boundaries, you need a different approach.
Options I actually use:
- Tokenize on separators (fast, predictable, no regex)
- Use regex with boundaries (powerful, but heavier)
- Use a search/index layer (if the problem is really “search,” not “substring”)
A simple tokenization approach:
bool ContainsWord(string text, string word)
{
if (string.IsNullOrWhiteSpace(text)) return false;
if (string.IsNullOrWhiteSpace(word)) return false;
var tokens = text.Split(‘ ‘, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var token in tokens)
{
if (string.Equals(token, word, StringComparison.CurrentCultureIgnoreCase))
return true;
}
return false;
}
It’s not a full NLP tokenizer, but it avoids the most obvious substring false positives.
Mistake 3: null handling hidden behind assumptions
If term can be null, text.Contains(term) will throw. I prefer guarding early:
if (string.IsNullOrEmpty(term))
{
return false; // or treat empty as match, but choose deliberately
}
return text.Contains(term, StringComparison.OrdinalIgnoreCase);
Mistake 4: mixing culture rules in machine checks
I’ve seen bugs triggered only on certain server locales because code used a culture-aware mode for protocol tokens.
Rule I follow:
- If it’s a spec, config key, header, ID, or filename rule: ordinal.
- If it’s meant to read like human language: culture-aware.
Mistake 5: checking extensions with Contains()
Bad:
bool isPdf = fileName.Contains(".pdf", StringComparison.OrdinalIgnoreCase);
Better:
bool isPdf = fileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase);
Even better: use Path.GetExtension(fileName) if you’re handling actual file paths.
Mistake 6: confusing string.Contains with LINQ Contains
This one is sneaky in code review because they look similar.
text.Contains("abc")searches within a string.list.Contains("abc")checks membership in a collection.
And then there’s the trap:
// This is membership check (exact item), not substring search.
bool ok = allowedDomains.Contains(userDomain);
That might be correct… or it might be wrong if allowedDomains came from a comma-separated string and you never actually parsed it. When I see this, I ask: “Are these tokens or text?” If they’re tokens, represent them as a set with a StringComparer.
Delimiter-Aware Checks: My Go-To Pattern for Config Lists
If you’re reading a list-like setting (environment variable, config entry, header), it’s usually better to parse once and then use exact checks.
Example: ENABLED_MODULES=payments,search,reporting
using System;
using System.Collections.Generic;
public static class FeatureFlags
{
public static HashSet ParseEnabledModules(string raw)
{
var set = new HashSet(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(raw)) return set;
foreach (var item in raw.Split(‘,‘, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
set.Add(item);
}
return set;
}
}
Then checks become unambiguous:
var enabled = FeatureFlags.ParseEnabledModules(Environment.GetEnvironmentVariable("ENABLED_MODULES"));
bool paymentsOn = enabled.Contains("payments");
I like this because it turns a “string contains” question into a “set membership” question, which is almost always what configuration lists really are.
Contains() vs Regex vs Search: What I Choose and Why
I keep this mental model:
Contains()is for a literal substring check.Regexis for patterns (and comes with extra overhead and complexity).- Full-text search (or indexing) is for lots of queries over lots of documents.
Traditional vs modern approaches (practical guidance)
Traditional approach
—
text.Contains("marker")
text.Contains("marker", StringComparison.Ordinal) text.ToLower().Contains("marker")
text.Contains("marker", StringComparison.OrdinalIgnoreCase) Contains() then IndexOf()
IndexOf() once + >= 0 Hand-rolled parsing
Many Contains() calls
If you’re writing an API gateway, a log pipeline, or anything that touches untrusted input, my bias is: keep checks deterministic, keep rules explicit, and avoid surprising culture behavior.
Testing Your Assumptions (What I Do in 2026 Projects)
String searching feels too small to test—until it breaks production behavior in one locale or one edge case.
Here are test cases I regularly add:
- Empty needle: what should your method do with
""? - Null needle: do you throw, or do you treat it as false?
- Casing: do you want
OrdinalIgnoreCaseor culture-aware rules? - Unicode edge cases: at least one test that proves you’re not accidentally using culture rules for machine tokens.
A small xUnit-style example: a helper with deliberate semantics
Let’s say I want a helper that:
- returns
falsefornull/ empty tokens (I don’t want empty to match everything), - throws if
textisnull(callers must pass a real string), - and uses
OrdinalIgnoreCasebecause I’m matching machine-ish markers.
using System;
public static class TextChecks
{
public static bool ContainsToken(string text, string token)
{
if (text is null) throw new ArgumentNullException(nameof(text));
if (string.IsNullOrEmpty(token)) return false;
return text.Contains(token, StringComparison.OrdinalIgnoreCase);
}
}
Now the tests make the semantics explicit:
using Xunit;
public class TextChecksTests
{
[Fact]public void ContainsTokenNullTextThrows()
{
Assert.Throws(() => TextChecks.ContainsToken(null, "abc"));
}
[Theory] [InlineData(null)] [InlineData("")]public void ContainsTokenNullOrEmptyTokenReturnsFalse(string token)
{
Assert.False(TextChecks.ContainsToken("hello", token));
}
[Fact]public void ContainsToken_UsesOrdinalIgnoreCase()
{
Assert.True(TextChecks.ContainsToken("ReleaseNotes-2026", "notes"));
Assert.False(TextChecks.ContainsToken("Release-2026", "notes"));
}
}
A culture test pattern (use sparingly)
If your code depends on culture-aware behavior, I like to lock it down with an explicit culture test so future refactors don’t silently change behavior.
Important: when you change CurrentCulture in tests, isolate it carefully (and avoid running such tests in parallel unless you scope culture per-thread in your test runner).
using System;
using System.Globalization;
using Xunit;
public class CultureTests
{
[Fact]public void UiSearch_UsesCurrentCultureIgnoreCase()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
string text = "Istanbul";
string term = "istanbul";
// The assertion here is not “one culture is right.”
// The point is: this behavior is culture-dependent by design.
bool hit = text.Contains(term, StringComparison.CurrentCultureIgnoreCase);
Assert.True(hit);
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
}
I don’t add culture tests everywhere. I add them when:
- the feature is user-facing search/sort, and
- the behavior must remain culture-aware, and
- a future “optimization” might accidentally switch it to ordinal.
My Code Review Checklist for Contains()
When I review a change that introduces Contains(), I mentally run this checklist:
- Is this a token check or a text search? If token: prefer
Ordinalor parsing. - Should the match be case-insensitive? If yes: choose
OrdinalIgnoreCaseorCurrentCultureIgnoreCasedeliberately. - Could this produce false positives? If yes: use
StartsWith,EndsWith,Equals, or parse tokens. - Is empty input meaningful? If empty shouldn’t match: guard
string.IsNullOrEmpty(needle). - Is this in a hot path? If yes: avoid normalization allocations; consider one
IndexOf(); consider spans. - Is structured data involved? If yes: parse structure instead of searching raw text.
This checklist is boring, and that’s the point. The fastest way to avoid string bugs is to make the decisions explicit and repeatable.
Quick FAQ
Does Contains() allocate?
A plain text.Contains("needle", ...) does not require you to allocate new strings. The most common allocation bugs come from normalization patterns like ToLowerInvariant() or unnecessary Substring() calls inside loops.
Should I always use OrdinalIgnoreCase?
No. I use OrdinalIgnoreCase for machine-readable tokens and stable behavior across environments. For user-facing search in natural language, I usually prefer CurrentCultureIgnoreCase so casing rules match user expectations.
Is Contains() safe for parsing?
It’s safe in the sense that it won’t corrupt memory, but it’s unsafe as a parsing strategy. If you’re dealing with JSON, XML, CSV, or protocol formats, substring checks can produce both false positives and false negatives. Parse the structure when correctness matters.
When should I switch to regex?
When your requirement is a pattern, not a substring. If you find yourself stacking multiple Contains() checks to approximate boundaries and optional characters, that’s often a sign you want regex or a small parser.
Wrap-Up
I like string.Contains() a lot—when I use it for what it is: a literal substring check with well-defined comparison rules. The problems start when developers use it as a fuzzy, culture-magic “search” primitive.
My consistent approach is simple:
- Choose
StringComparisonup front. - Use
Ordinal/OrdinalIgnoreCasefor machine tokens. - Use culture-aware modes for user-facing text.
- Prefer
IndexOf()when you need an index or you care about doing one scan. - Avoid
Contains()for authorization checks and structured parsing.
If you adopt just those habits, Contains() stops being a bug magnet and becomes the clean, readable tool it was meant to be.


