A string-handling code review that combines the Phase 2 string topics: immutability, comparison, culture, formatting, concatenation, truncation, null handling and logging, with a rewritten version.
Almost every line has a string bug. name.Trim() does nothing because strings are immutable. ToLower()/ToUpper() comparisons are culture-sensitive, so they break under Turkish and allocate; I'd use OrdinalIgnoreCase. date.ToString() makes culture-dependent keys and CSV, so I'd use the invariant culture and ISO formats. IndexOf("@") is a culture-sensitive string search and is weak validation anyway; I'd use MailAddress.TryCreate. += in a loop is O(n²), so I'd stream rows instead. Substring(0, 50) throws on short names and can split emoji. The interpolated log message kills structured logging. My rewrite fixes each of these and adds explicit null handling.
I would go through each line, explain the category of bug (culture, correctness, performance, security/privacy, observability), and then show a rewrite. The theme: strings that are identifiers or machine data need ordinal/invariant handling; strings for humans need care with Unicode; and building large output needs streaming, not concatenation.
customer.Name.ToLower() + "_" + date.ToString() (cache key)
ToLower() uses the current culture — Turkish I lowercases to ı, so the same customer gets different keys on different servers.date.ToString() uses current-culture date and time formats — 30/10/2026 8:38:00 pm on one server, 10/30/2026 8:38:00 PM on another — so cache hit rates collapse and distributed caches (Redis) get duplicate keys.status.ToUpper() == "ACTIVE"
string.Equals(status, "Active", StringComparison.OrdinalIgnoreCase).email.IndexOf("@") > 0 (validation)
IndexOf(string) is culture-sensitive by default; under ICU, ignorable characters can change results.a@, a@@b, a@b@c, and rejects nothing meaningful. Use MailAddress.TryCreate(email, out _) for format sanity, a length limit, and ultimately email confirmation for real validation.result += line (CSV rows)
=, +, -, @ enable CSV/formula injection when opened in Excel.name.Substring(0, 50)
ArgumentOutOfRangeException when name is shorter than 50 — the common case.NullReferenceException for null names.name.Trim(); without assignment
$"Exported {count} rows" in logging
count is not a queryable property, every message is unique, and formatting happens even when the level is off.public sealed partial class CustomerExporter(ILogger<CustomerExporter> logger, IDistributedCache cache)
{
private const int MaxNameLength = 50;
public async Task ExportAsync(IAsyncEnumerable<Customer> customers, Stream output, DateOnly date, CancellationToken ct)
{
await using var writer = new StreamWriter(output, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), bufferSize: 64 * 1024);
await writer.WriteLineAsync("CustomerId,Name,Email,Status");
int count = 0;
await foreach (var c in customers.WithCancellation(ct))
{
if (c.Status != CustomerStatus.Active) continue; // enum, not string
if (!MailAddress.TryCreate(c.Email, out _)) continue; // or reject/report
string name = Truncate(c.Name.Trim(), MaxNameLength); // result assigned
await writer.WriteLineAsync(string.Create(CultureInfo.InvariantCulture,
$"{c.Id},{Csv(name)},{Csv(c.Email)},{c.Status}"));
count++;
}
LogExported(count, date);
await cache.SetStringAsync(CacheKey(date), count.ToString(CultureInfo.InvariantCulture), ct);
}
private static string CacheKey(DateOnly date) =>
string.Create(CultureInfo.InvariantCulture, $"export:active-customers:{date:yyyy-MM-dd}");
private static string Truncate(string value, int maxTextElements)
{
var info = new StringInfo(value);
return info.LengthInTextElements <= maxTextElements ? value : info.SubstringByTextElements(0, maxTextElements);
}
private static string Csv(string value)
{
if (value.Length > 0 && "=+-@\t\r".Contains(value[0])) value = "'" + value; // formula injection guard
return value.AsSpan().IndexOfAny(",\"\r\n") >= 0 ? $"\"{value.Replace("\"", "\"\"")}\"" : value;
}
[LoggerMessage(Level = LogLevel.Information, Message = "Exported {Count} active customers for {ExportDate}")]
private partial void LogExported(int count, DateOnly exportDate);
}
Key changes: enum status; ordinal/invariant handling everywhere; cache key based on a stable, non-personal identifier and ISO date; streaming output with an explicit encoding (BOM because this CSV goes to Excel users); proper CSV escaping and formula-injection guard; safe truncation by text elements; assigned Trim(); source-generated structured logging. In a real system I would use a CSV library (CsvHelper) rather than hand-rolling escaping.
I'd group comments by severity: correctness/security first (Substring crash, CSV injection, culture-dependent keys), then performance (concatenation, allocations), then maintainability (magic strings, logging). And I'd suggest turning on analysers CA1304/CA1305/CA1307/CA1309/CA1310/CA1311, CA1806 and CA2254 (template should be a static expression) so the build catches most of this next time.
This review checks breadth across all string topics and the ability to prioritise and generalise findings.
Green flags
Trim, culture-sensitive case and formatting, IndexOf(string), O(n²) concatenation, Substring bounds, CSV escaping, log templates, PII, null return.Red flags
+= loop.ToLower() everywhere as the fix.ToLower(), ToUpper(), ToString() on numbers and dates, IndexOf(string), StartsWith(string) and string.Compare all consult CultureInfo.CurrentCulture when you don't pass a culture or comparison. In ASP.NET Core with request localisation, the current culture can even differ per request, so a cache key produced in one request may not match the key produced in another. Identifiers and keys must be built with ordinal operations and the invariant culture — or better, from values that are already culture-free (IDs, enums, ISO dates).
ToLowerInvariant() / ToUpperInvariant() use invariant simple case mapping: stable, but they still allocate.StringComparer.OrdinalIgnoreCase) avoid allocation and are faster.The RFC 5322 grammar is enormous; regexes that try to implement it are either wrong or vulnerable to ReDoS. Practical layers:
MailAddress.TryCreate (.NET 5+), or [EmailAddress] / FluentValidation's EmailAddress() (which in recent versions only checks for an @ with text either side, by design).IdnMapping for internationalised domains); the local part is technically case-sensitive.RFC 4180 rules: fields containing a comma, quote, CR or LF must be enclosed in double quotes, and quotes inside are doubled. Additionally, spreadsheet applications interpret fields starting with =, +, -, @ (and tab/CR) as formulas, which allows CSV injection (data exfiltration via =HYPERLINK(...), or DDE attacks in older Excel). OWASP recommends prefixing such values with a single quote and quoting the field. Libraries like CsvHelper handle quoting, culture (CsvConfiguration(CultureInfo.InvariantCulture)) and streaming.
For exports, the cost model of building a string is:
+= → O(n²) time, n intermediate strings, final string on the LOH (> 85 KB).StringBuilder → O(n) time but still the whole export in memory, plus one final large string copy.StreamWriter over the response stream → O(n) time, constant memory (buffer size), and the client starts downloading immediately.In ASP.NET Core, return the export by writing to Response.Body (or Results.Stream with a callback); for very large exports, generate to Azure Blob Storage and return a link.
Substring(0, n) fails three ways: short strings (exception), null (NRE), and splitting surrogates or combining sequences (invalid or garbled text). Options:
value[..Math.Min(value.Length, n)] fixes the exception but not Unicode splitting.StringInfo.SubstringByTextElements) for user-visible limits.Rune.DecodeLastFromUtf8 / Utf8.FromUtf16 with OperationStatus).ILogger providers treat the message template as an event type and the placeholders as properties. Application Insights stores them as customDimensions; Seq and Elastic index them. [LoggerMessage] source generation:
IsEnabled before doing any work,object[]),EventId for alerting.Analyser CA2254 warns when the template is not a constant (e.g. interpolated), and CA1848 suggests LoggerMessage for performance.
tr-TR and en-US cultures and compare keys.O'Brien, Jr., =HYPERLINK("http://x"), and an emoji nickname through the original and rewritten exporter, then open the CSV in Excel.dotnet-counters while exporting 1 million rows with +=, StringBuilder and streaming.using System.Globalization;
using System.Net.Mail;
using System.Text;
using Microsoft.Extensions.Logging;
namespace Strings;
public enum CustomerStatus { Unknown = 0, Active = 1, Suspended = 2 }
public sealed record Customer(int Id, string Name, string Email, CustomerStatus Status);
public sealed record Order(decimal Total, DateOnly Date);
public static partial class ExportLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "Exported {Count} rows for customer {CustomerId}")]
public static partial void Exported(ILogger logger, int count, int customerId);
}
public sealed class OrderExporter(ILogger<OrderExporter> logger)
{
public static string CacheKey(Customer c, DateOnly date) =>
string.Create(CultureInfo.InvariantCulture, $"export:{c.Id}:{date:yyyy-MM-dd}"); // no PII, invariant
public async Task<bool> ExportAsync(Customer customer, IReadOnlyList<Order> orders, Stream output, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(customer);
if (customer.Status != CustomerStatus.Active) return false; // enum, not ToUpper()
if (!MailAddress.TryCreate(customer.Email, out _)) return false; // real validation
string name = Truncate(customer.Name.Trim(), 50); // assigned, safe truncation
await using var writer = new StreamWriter(output, new UTF8Encoding(true), leaveOpen: true); // BOM for Excel
foreach (var o in orders)
{
await writer.WriteLineAsync(string.Create(CultureInfo.InvariantCulture,
$"{Csv(name)},{o.Total:F2},{o.Date:yyyy-MM-dd}"));
}
ExportLog.Exported(logger, orders.Count, customer.Id); // structured, no PII
return true;
}
private static string Truncate(string value, int max)
{
if (value.Length <= max) return value;
var info = new StringInfo(value);
return info.LengthInTextElements <= max ? value : info.SubstringByTextElements(0, max);
}
private static string Csv(string field)
{
if (field.Length > 0 && "=+-@".Contains(field[0])) field = "'" + field; // formula injection guard
return field.AsSpan().IndexOfAny(",\"\r\n") >= 0
? $"\"{field.Replace("\"", "\"\"", StringComparison.Ordinal)}\""
: field;
}
}
What happens: keys use ToLower(), ToString() of dates or numbers, and display names.
Why it is wrong: keys differ between servers and requests; cache hit rates drop and distributed caches fill with duplicates.
Do instead: build keys from IDs, enums and ISO dates with the invariant culture.
What happens: status.ToUpper() == "ACTIVE" scattered through the codebase.
Why it is wrong: typos, casing issues and culture problems; no compile-time checking.
Do instead: use enums (with string conversion at the boundary) or constants plus ordinal comparison.
IndexOf("@") as email validationWhat happens: weak checks accept invalid addresses and culture-sensitive search adds surprises.
Why it is wrong: bad data reaches downstream systems; bounces and failed notifications follow.
Do instead: use MailAddress.TryCreate plus length limits, and verify ownership by email confirmation.
What happens: result += line; and no escaping.
Why it is wrong: O(n²) performance, broken rows when values contain commas or quotes, and CSV injection in Excel.
Do instead: stream rows to a writer with a CSV library or correct escaping and a formula-injection guard.
Substring with fixed lengthsWhat happens: name.Substring(0, 50) on variable-length input.
Why it is wrong: throws for short strings and can split Unicode sequences.
Do instead: guard lengths and truncate by text elements or bytes, or reject over-long input.
What happens: name.Trim();, email.ToLowerInvariant(); on their own lines.
Why it is wrong: strings are immutable; nothing changes.
Do instead: assign results and enable CA1806.
What happens: _logger.LogInformation($"Exported {count} rows").
Why it is wrong: loses structured properties, prevents grouping, and formats even when disabled.
Do instead: message templates or [LoggerMessage].
What happens: customer names or emails in cache keys, file names and log messages. Why it is wrong: spreads personal information into systems without access controls or retention policies — a Privacy Act (APP 11) and breach-impact risk. Do instead: use opaque IDs, and log only what is needed.
What happens: amounts appear as 1234,50 on a server with a European culture.
Why it is wrong: consumers misread values — potentially by a factor of 100.
Do instead: use invariant culture (or the consumer's documented format) for exported data.
What happens: review comments fix this method but the same patterns recur elsewhere. Why it is wrong: the class of bug isn't prevented. Do instead: enable globalisation and reliability analysers as errors, and add tests with tricky data (Turkish culture, emoji, commas, formulas).
A senior code review turns individual fixes into guardrails.
| Category | Guardrail |
|---|---|
| Culture and comparison | CA1304/1305/1307/1309/1310/1311 as errors |
| Ignored results | CA1806 |
| Logging | CA2254, [LoggerMessage], no PII in templates |
| Nullability | NRT warnings as errors |
| CSV/Excel output | shared, tested CSV writer with escaping and formula guard |
| Large exports | streaming pattern in the team's export template |
Ordinal comparison compares the raw UTF-16 code units, so it's fast and identical on every machine. Culture-sensitive comparison applies a language's rules through ICU (accents, ignorable characters, ß versus ss, Turkish dotted I), so results can change with the culture or ICU version. The trap is the defaults: ==, Equals and Contains are ordinal, but StartsWith(string), IndexOf(string), Compare and CompareTo are culture-sensitive. I use Ordinal/OrdinalIgnoreCase for identifiers, keys, paths, headers and anything security-related, and CurrentCulture only for sorting or searching text a user sees. For collections I pass a StringComparer.
$"Hello {name}" is compiled, not parsed at run time. With a string target, C# 10+ uses DefaultInterpolatedStringHandler, which formats each hole straight into a pooled buffer without boxing, then allocates the final string once. Holes take alignment and format ({total,10:C}), and formatting uses the current culture, so for machine-readable text I use string.Create(CultureInfo.InvariantCulture, $"..."). Assigned to FormattableString, it keeps the format and arguments separate, which is how EF Core's FromSql($"...") turns holes into SQL parameters. Raw $$""" strings help with JSON. Two traps: interpolating into raw SQL causes SQL injection, and interpolating into log templates destroys structured logging.
How to tell it (STAR)
**S:** Our portal's CSV export [at company] crashed for some customers, broke columns in Excel and was logging customer names. **T:** I took ownership of fixing all three. **A:** I reproduced each issue, grouped them by root cause, rewrote the exporter with streaming, proper CSV escaping, invariant formatting and structured logging with ids, and turned on the analysers that would have caught them. **R:** The errors stopped, the column complaints ended and the privacy finding was closed [timeframe].
Last updated 30 Oct 2026.