The config that never matched
You are building a CLI tool. The user passes --mode=DEBUG. Your code checks if mode == "debug". The check fails. You print both strings. They look identical. The logic is broken. You stare at the screen wondering why Rust is being difficult.
Rust is not being difficult. Rust is doing exactly what you asked. You asked for equality. Rust compared the bytes. The byte for D is not the byte for d. The comparison returned false. The lesson starts here: Rust never guesses your intent. It compares what you give it. If you want case-insensitive matching, you must ask for it explicitly.
This article covers how to compare strings correctly, why some strings that look identical fail to match, and how to pick the right comparison tool without paying for performance you don't need.
Content, not addresses
Rust compares strings by content, not memory address. If two strings hold the same sequence of bytes, they are equal. It does not matter if one lives in a String on the heap and the other is a &str slice embedded in the binary. The compiler handles the ownership gap automatically.
Think of it like comparing two receipts. If the text matches, the receipts match. It does not matter which register printed them or where they are stored. In languages like C, comparing strings often means comparing pointers. If the pointers differ, the strings are "not equal" even if the text is the same. Rust avoids this trap. The == operator always checks the data.
Rust also distinguishes between exact equality and ordering. The == operator checks if strings are identical. The < and > operators check lexicographical order. Ordering follows Unicode code points. The character a has a code point of 97. The character b has a code point of 98. Since 97 is less than 98, "apple" is less than "banana".
The basics: operators and coercion
You can use standard comparison operators on String, &str, and &String. Rust implements the PartialEq and PartialOrd traits for these types. You can mix owned strings and slices without manual conversion.
fn main() {
let owned = String::from("hello");
let slice = "hello";
let other = String::from("world");
// Deref coercion kicks in: String becomes &str automatically.
// No .as_str() call is needed.
if owned == slice {
println!("Match found!");
}
// Lexicographical comparison follows Unicode code points.
// 'a' (97) is less than 'b' (98).
if "apple" < "banana" {
println!("'apple' comes before 'banana'");
}
// Comparing owned strings works directly too.
// The compiler borrows the left side to compare bytes.
if owned != other {
println!("They are different");
}
}
When you write owned == slice, Rust sees a String on the left and a &str on the right. It does not panic. It does not ask for a conversion. The Deref trait bridges the gap. String implements Deref<Target=str>, which means it can automatically coerce to &str in contexts that expect a reference. The compiler rewrites the comparison to &str == &str. The generated code walks the bytes of both strings and checks for differences. If the lengths differ, it returns false immediately. If the lengths match, it compares byte by byte until it finds a mismatch or reaches the end.
This coercion is a community convention point. Calling .as_str() on a String before comparing is redundant. Writing s.as_str() == "test" compiles, but the community reads it as noise. It signals that the developer does not trust deref coercion. Skip the method call. Let the compiler do the work.
Case sensitivity is the real boss
The most common string comparison pitfall is case sensitivity. Rust's == is case-sensitive. "Rust" is not equal to "rust". This is a feature, not a bug. Case sensitivity ensures that file systems, identifiers, and protocols behave predictably. If Rust silently ignored case, you would get subtle bugs where "Config" and "config" collide.
For case-insensitive comparison, Rust provides eq_ignore_ascii_case. This method is available on &str and String. It compares strings while ignoring the case of ASCII letters. It runs in O(N) time and does not allocate memory.
fn main() {
let s1 = "Rust";
let s2 = "rust";
// ASCII case folding. Fast. No allocation.
// Handles A-Z and a-z correctly.
if s1.eq_ignore_ascii_case(s2) {
println!("Match ignoring ASCII case");
}
}
Use eq_ignore_ascii_case for configuration keys, HTTP headers, and identifiers where case varies but the text is pure ASCII. It is the standard tool for this job. The method name is explicit. It tells the reader exactly what is happening: ASCII case is ignored. Nothing more, nothing less.
The Unicode trap: identical looks, different bytes
Case sensitivity is only the first layer. The deeper trap involves Unicode normalization. Strings can look identical to a human reader but contain different bytes. This happens when text is composed differently.
Consider the character é. It can be represented as a single code point U+00E9 (composed form). It can also be represented as e followed by a combining acute accent U+0301 (decomposed form). Visually, they are the same. In Rust, they are different.
fn main() {
// Single code point for é.
let composed = "café";
// 'e' plus combining accent. Looks identical in output.
let decomposed = "cafe\u{0301}";
// They look the same in the terminal.
// They are NOT equal in Rust.
// == compares bytes, not visual appearance.
if composed == decomposed {
println!("Equal");
} else {
println!("Not equal: {} vs {}", composed.len(), decomposed.len());
}
}
This code prints "Not equal". The composed string has 4 bytes. The decomposed string has 5 bytes. The comparison fails. This bug appears when users paste text from websites, documents, or mobile keyboards. Different sources use different normalization forms. If you store user input and compare it later, you might get false negatives.
The standard library does not normalize strings automatically. Normalization is expensive and depends on the use case. If you need to compare strings that might have different normalization forms, you must normalize them first. The unicode-normalization crate provides functions to convert strings to NFC or NFD forms.
// Requires `unicode-normalization` crate.
// use unicode_normalization::UnicodeNormalization;
fn normalized_eq(a: &str, b: &str) -> bool {
// Normalize both to NFC before comparing.
// This allocates new strings.
let a_nfc: String = a.nfc().collect();
let b_nfc: String = b.nfc().collect();
a_nfc == b_nfc
}
Normalization allocates memory. It changes the bytes. Use it only when you know your data comes from untrusted sources with mixed normalization. For internal identifiers, enforce a single normalization form at the boundary. Do not normalize in the hot path.
Performance: references, allocations, and loops
String comparison is O(N) where N is the length of the string. You cannot compare two strings faster than reading their bytes. If you are comparing large strings in a performance-critical loop, the cost adds up.
The biggest performance win is avoiding allocation. Comparing &str references is cheap. The comparison reads the bytes without copying. If you have a Vec<String>, iterate with references.
fn find_match(haystack: &[String], needle: &str) -> Option<usize> {
// Iterate by reference. No cloning.
// Comparison borrows the String data.
for (i, item) in haystack.iter().enumerate() {
if item == needle {
return Some(i);
}
}
None
}
If you accidentally move String values into a comparison loop, you trigger allocations and drops. The compiler usually catches this with E0382 (use of moved value) if you try to reuse the string. If you clone strings just to compare them, you waste memory and time.
// BAD: Cloning allocates memory unnecessarily.
// The comparison only needs a reference.
if haystack[i].clone() == needle { ... }
// GOOD: Borrowing is free.
if haystack[i] == needle { ... }
Another performance consideration is cmp versus ==. The == operator returns a boolean. The cmp method returns an Ordering enum (Less, Equal, Greater). If you are sorting strings, use cmp. It stops at the first difference and returns the result. If you use == inside a sort, you pay the full comparison cost even when you only need to know which is smaller.
fn main() {
let a = "apple";
let b = "banana";
// cmp returns Ordering.
// Efficient for sorting algorithms.
match a.cmp(b) {
std::cmp::Ordering::Less => println!("a < b"),
std::cmp::Ordering::Equal => println!("a == b"),
std::cmp::Ordering::Greater => println!("a > b"),
}
}
Pitfalls and compiler errors
If you try to compare a String with a type that does not implement PartialEq, the compiler rejects the code. You will see E0277 (trait bound not satisfied). Rust will not guess that you want to compare a string to a number or a byte vector.
fn main() {
let s = String::from("123");
let n = 123;
// Error[E0277]: can't compare `String` with `{integer}`
// The trait `PartialEq<{integer}>` is not implemented for `String`
if s == n {
println!("Match");
}
}
If you try to compare a String with a Vec<u8>, you get the same error. Strings are UTF-8 text. Byte vectors are raw data. Rust forces you to be explicit about the conversion. Use s.as_bytes() if you need to compare the underlying bytes.
fn main() {
let s = String::from("hello");
let bytes = vec![104, 101, 108, 108, 111];
// Compare bytes directly.
// as_bytes() returns &[u8].
if s.as_bytes() == bytes.as_slice() {
println!("Bytes match");
}
}
Another subtle pitfall is the to_lowercase() allocation. Developers often reach for to_lowercase() to handle case-insensitive comparison. This method returns a new String. It allocates memory. If you call it in a loop, you generate garbage.
// BAD: Allocates a new String every iteration.
// Slow and memory-heavy.
if input.to_lowercase() == "debug" { ... }
// GOOD: No allocation.
// Use eq_ignore_ascii_case for ASCII.
if input.eq_ignore_ascii_case("debug") { ... }
If you must handle Unicode case folding, accept the allocation. There is no zero-allocation Unicode case folding in the standard library. The cost is the price of correctness.
Decision matrix
Use == when you need an exact, byte-for-byte match. This is the default for 90% of comparisons. It is fast, safe, and handles String and &str mixing automatically.
Use eq_ignore_ascii_case when comparing config keys, headers, or identifiers where case varies but the text is pure ASCII. It runs in O(N) time without allocating memory.
Use to_lowercase() when you must handle Unicode case folding, like German ß or Turkish İ. Accept the allocation cost for correctness. Normalize first if your data comes from mixed sources.
Use the unicode-normalization crate when you need to compare strings that might have different normalization forms. Enforce normalization at the input boundary to avoid repeated work.
Use cmp when sorting strings or when you need to know the ordering, not just equality. It returns Ordering and is optimized for comparison loops.
Use as_bytes() when you need to compare the raw UTF-8 bytes, not the text content. This is rare and usually indicates a protocol-level check.
Pick the tool that matches your data. Don't pay for Unicode if you only have ASCII. Don't allocate if you can borrow. Trust the borrow checker. It usually has a point.