To compare two strings ignoring case in C++, check that the lengths match, then compare character by character through std::tolower with each character cast to unsigned char first. There is no case-insensitive method on std::string, so you write a small helper. The cast is not optional: skipping it is undefined behavior.
std::equal with a lambda that lowercases both characters after an unsigned char cast, guarded by a size check. That is the correct, portable, allocation-free version. For ordering rather than equality, use std::lexicographical_compare with the same predicate. All of this is ASCII-only — for real internationalized text you need ICU.Every program below was compiled with g++ -std=c++20 -Wall -Wextra -pedantic on GCC 13.3 on x86-64 Linux and run, and every output is real. The approaches are ordered from the version you will find in most tutorials to the version you should actually ship.
The manual loop, and the unsigned char trap
Start with the obvious loop. It works, but only because of one line that most published versions omit.
#include <cctype>
#include <iostream>
#include <string>
// The version most tutorials ship. The cast is the part they leave out.
bool iequals_loop(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i) {
unsigned char ca = static_cast<unsigned char>(a[i]);
unsigned char cb = static_cast<unsigned char>(b[i]);
if (std::tolower(ca) != std::tolower(cb)) return false;
}
return true;
}
int main() {
std::cout << std::boolalpha;
std::cout << "Hello vs hELLO : " << iequals_loop("Hello", "hELLO") << '\n';
std::cout << "Hello vs Hell : " << iequals_loop("Hello", "Hell") << '\n';
std::cout << "abc vs abd : " << iequals_loop("abc", "abd") << '\n';
// Why the cast matters: on a platform where char is signed, a byte above
// 0x7F becomes a negative int. tolower() requires a value representable
// as unsigned char or EOF, so passing -23 is undefined behavior.
char raw = static_cast<char>(0xE9); // 'e' with acute in Latin-1
std::cout << "char is " << (static_cast<int>(raw) < 0 ? "signed" : "unsigned")
<< " here, raw as int = " << static_cast<int>(raw) << '\n';
std::cout << "cast to unsigned char = "
<< static_cast<int>(static_cast<unsigned char>(raw)) << '\n';
}Hello vs hELLO : true
Hello vs Hell : false
abc vs abd : false
char is signed here, raw as int = -23
cast to unsigned char = 233std::tolower takes an int, and the standard requires that value to be representable as unsigned char or equal to EOF. On x86-64 Linux, char is signed, so byte 0xE9 arrives as -23. Glibc implements tolower as a table lookup offset by the argument, so a negative index reads outside the intended table. It usually appears to work, which is what makes it dangerous: the same code can crash or return garbage on a different libc, and it is undefined behavior everywhere. The cast to unsigned char turns -23 into 233 and the problem disappears.std::equal with a lambda predicate
The same logic, less code, and one detail improved: the algorithm handles the iteration and you supply only the comparison rule.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
#include <string_view>
bool iequals(std::string_view a, std::string_view b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(),
[](unsigned char x, unsigned char y) {
return std::tolower(x) == std::tolower(y);
});
}
int main() {
std::cout << std::boolalpha;
std::cout << iequals("GeekBlog", "geekblog") << '\n';
std::cout << iequals("GeekBlog", "geekblogs") << '\n';
std::string s = "README";
std::cout << iequals(s, "readme") << '\n'; // std::string converts freely
const char* p = "ReadMe";
std::cout << iequals(p, s) << '\n'; // so does const char*
}Notice the lambda parameters are declared unsigned char. That is the cast, moved to a place where you cannot forget it. Taking std::string_view rather than const std::string& means std::string, const char* and string literals all bind with no temporary allocated. The size check first is what makes the whole thing short-circuit on the cheap case.
Ordering, not just equality
Equality is not enough if you want to sort, or to use the strings as keys in a std::map. For that you need a strict weak ordering, which is what std::lexicographical_compare gives you.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
// Ordering, not just equality. Returns true when a sorts before b.
bool iless(std::string_view a, std::string_view b) {
return std::lexicographical_compare(
a.begin(), a.end(), b.begin(), b.end(),
[](unsigned char x, unsigned char y) {
return std::tolower(x) < std::tolower(y);
});
}
int main() {
std::vector<std::string> names{"delta", "Alpha", "charlie", "bravo", "ALPHA2"};
std::sort(names.begin(), names.end());
std::cout << "plain sort:";
for (const auto& n : names) std::cout << ' ' << n;
std::sort(names.begin(), names.end(), iless);
std::cout << "\ncase-insensitive:";
for (const auto& n : names) std::cout << ' ' << n;
std::cout << '\n';
}plain sort: ALPHA2 Alpha bravo charlie delta
case-insensitive: Alpha ALPHA2 bravo charlie deltaThe contrast is the point. Byte-wise sorting puts every capital letter before every lowercase letter, because 'A' is 65 and 'a' is 97. That is why the plain sort produced ALPHA2 before Alpha, which no user would call alphabetical. Note also that this comparator treats “Alpha” and “ALPHA” as equivalent without treating them as identical, which is precisely what std::weak_ordering describes if you are writing a three-way comparison operator for a case-insensitive string type.
Transforming copies, and why it costs more than you think
Lowercasing both strings and comparing the results is the approach people reach for first. It is correct, and it is the slowest option in this article.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
std::string to_lower_copy(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
int main() {
std::string a = "Mixed Case Input";
std::string b = "mixed case input";
// Two heap allocations for one boolean answer.
bool same = to_lower_copy(a) == to_lower_copy(b);
std::cout << std::boolalpha << "equal: " << same << '\n';
// Where it does pay off: normalize once, compare many times.
std::string key = to_lower_copy(a);
std::cout << "normalized key: " << key << '\n';
std::cout << "matches b: " << (key == to_lower_copy(b)) << '\n';
}The static_cast on the return value matters too. std::tolower returns int, and assigning that straight into a char produces a narrowing-conversion warning on most builds. Use this approach when you will compare the same value repeatedly, for example as a lookup key. For a one-shot comparison it does two allocations to answer one question.
strcasecmp, stricmp, and the portability problem
Both the C library functions do exactly what you want and are usually the fastest option available. Neither is in the C or C++ standard, and they have different names on different platforms.
#include <iostream>
#include <string>
#ifdef _WIN32
#include <string.h>
#define portable_stricmp _stricmp
#else
#include <strings.h>
#define portable_stricmp strcasecmp
#endif
int main() {
const char* a = "Hostinger";
const char* b = "HOSTINGER";
const char* c = "hostingerx";
std::cout << "a vs b: " << portable_stricmp(a, b) << '\n';
std::cout << "a vs c: " << portable_stricmp(a, c) << '\n';
std::cout << "c vs a: " << portable_stricmp(c, a) << '\n';
// Works on C strings only. A std::string needs .c_str(), and any
// embedded null byte silently ends the comparison.
std::string s = "Hostinger";
std::cout << "string vs b: " << portable_stricmp(s.c_str(), b) << '\n';
}a vs b: 0
a vs c: -120
c vs a: 120
string vs b: 0Only the sign is meaningful. The magnitude here is 120 because glibc returns the difference of the mismatching bytes, and 'x' is 120 with nothing to compare it against. Never test for equality with == 1 or == -1; test == 0, < 0 or > 0.
| Function | Platform | Header | Standardized? |
|---|---|---|---|
strcasecmp | Linux, macOS, BSD, POSIX | <strings.h> | POSIX only, not ISO C or C++ |
_stricmp | MSVC on Windows | <string.h> | Microsoft extension |
stricmp | Old MSVC, some embedded libcs | <string.h> | Deprecated by Microsoft in favor of _stricmp |
strncasecmp / _strnicmp | POSIX / MSVC | as above | Same split, with a length limit |
std::equal + lambda | Everywhere | <algorithm> | Yes. No #ifdef needed |
std::ranges::equal in C++20
Same algorithm, cleaner call site, and it accepts ranges directly instead of iterator pairs. It also supports projections, which are often tidier than a two-argument predicate.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <ranges>
#include <string>
#include <string_view>
// C++20: no .begin()/.end() pairs, and it short-circuits on length itself
// for sized ranges.
bool iequals(std::string_view a, std::string_view b) {
return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) {
return std::tolower(x) == std::tolower(y);
});
}
int main() {
std::cout << std::boolalpha;
std::cout << iequals("Spaceship", "SPACESHIP") << '\n';
std::cout << iequals("Spaceship", "Spaceships") << '\n';
// A projection is often tidier than a predicate.
auto lower = [](unsigned char c) { return std::tolower(c); };
std::string_view x = "Ranges", y = "RANGES";
std::cout << std::ranges::equal(x, y, {}, lower, lower) << '\n';
}The size check has disappeared from the helper because std::ranges::equal compares sizes itself when both arguments are sized ranges, which string_view is. That is a real improvement over the four-iterator std::equal overload, where forgetting the length check on differing-length inputs was a classic source of reading past the end.
A reusable iequals and a case-insensitive std::map
Wrap the comparator in a struct and you can hand it to any ordered container. The is_transparent typedef is the detail that makes lookups work without constructing a temporary std::string from every literal.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <map>
#include <string>
#include <string_view>
struct CaseInsensitiveLess {
using is_transparent = void; // lets you look up with a string_view
bool operator()(std::string_view a, std::string_view b) const {
return std::lexicographical_compare(
a.begin(), a.end(), b.begin(), b.end(),
[](unsigned char x, unsigned char y) {
return std::tolower(x) < std::tolower(y);
});
}
};
using HeaderMap = std::map<std::string, std::string, CaseInsensitiveLess>;
int main() {
std::cout << std::boolalpha;
HeaderMap headers{
{"Content-Type", "application/json"},
{"Cache-Control", "no-store"},
{"X-Request-Id", "9f2c"}
};
std::cout << headers.at("content-type") << '\n';
std::cout << headers.at("CACHE-CONTROL") << '\n';
std::cout << "found x-request-id: "
<< (headers.find("x-request-id") != headers.end()) << '\n';
// Inserting a differently cased duplicate updates nothing new: the key
// already exists as far as this comparator is concerned.
auto [it, inserted] = headers.insert({"CONTENT-TYPE", "text/html"});
std::cout << "inserted duplicate? " << inserted
<< ", value still " << it->second << '\n';
std::cout << "map size: " << headers.size() << '\n';
}application/json
no-store
found x-request-id: true
inserted duplicate? false, value still application/json
map size: 3std::unordered_map instead, you need two custom types: a hash that lowercases each byte before hashing, and an equality predicate. Getting one and not the other gives you a container that compiles and then loses entries.The honest limit: this only works for ASCII
Everything above is correct for unaccented English text and wrong for a great deal of the world’s writing. This is not a corner case you can defer; it is the default state of user input. Here is the failure, measured.
#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
#include <string_view>
bool ascii_iequals(std::string_view a, std::string_view b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(),
[](unsigned char x, unsigned char y) {
return std::tolower(x) == std::tolower(y);
});
}
int main() {
std::cout << std::boolalpha;
// German sharp s. The uppercase form is "SS" (two chars) or U+1E9E.
std::string strasse_lower = "straße";
std::string strasse_upper = "STRASSE";
std::cout << "strasse: " << ascii_iequals(strasse_lower, strasse_upper)
<< " (bytes " << strasse_lower.size() << " vs "
<< strasse_upper.size() << ")\n";
// Turkish dotless i. In tr-TR, uppercase of 'i' is U+0130, and the
// lowercase of 'I' is U+0131. ASCII rules get both wrong.
std::string turkish_i = "ıstanbul";
std::string ascii_i = "Istanbul";
std::cout << "istanbul: " << ascii_iequals(turkish_i, ascii_i)
<< " (bytes " << turkish_i.size() << " vs " << ascii_i.size() << ")\n";
// Even same-length accented text fails, because tolower() on a UTF-8
// byte sequence operates on bytes, not characters.
std::string cafe_lower = "café";
std::string cafe_upper = "CAFÉ";
std::cout << "cafe: " << ascii_iequals(cafe_lower, cafe_upper)
<< " (bytes " << cafe_lower.size() << " vs " << cafe_upper.size() << ")\n";
// Plain ASCII, which is the only case this is correct for.
std::cout << "ascii: " << ascii_iequals("Denver", "DENVER") << '\n';
}strasse: false (bytes 7 vs 7)
istanbul: false (bytes 9 vs 8)
cafe: false (bytes 5 vs 5)
ascii: true| Case | Why the ASCII version fails | What is actually needed |
|---|---|---|
| German ß vs SS | One character maps to two on uppercasing, so lengths differ in characters even when byte counts coincide | Full case folding, which can change string length |
| Turkish dotless ı and dotted İ | In Turkish, uppercase of i is İ and lowercase of I is ı. ASCII rules pair them the other way | Locale-aware folding with a tr locale |
| Accented Latin (é, ç, ñ) | tolower works on single bytes; a UTF-8 é is two bytes and neither one is a letter on its own | Unicode-aware iteration over code points |
| Greek final sigma ς vs σ | Two lowercase forms both uppercase to Σ, so equality depends on position in the word | Unicode case folding, not simple lowercasing |
| Composed vs decomposed é | U+00E9 and e + U+0301 look identical and compare unequal at every level | Normalization (NFC or NFD) before comparing |
The standard library gives you no good answer here. std::locale and std::collate exist, but their behavior depends on locales the host system may not have installed, and neither performs Unicode case folding or normalization. For real internationalized comparison, use ICU’s collation API with a strength of UCOL_SECONDARY, which treats case as insignificant while respecting accents, or ICU’s case-folding functions if you need a normalized key to store. It is a real dependency and worth it the moment your input is not guaranteed ASCII.
Performance: what the approaches actually cost
I benchmarked the four equality approaches on this machine with -O2, comparing roughly 2,600 sixteen-character strings against their neighbors, 200 times over. Treat the numbers as relative, not absolute; they will differ on your hardware and standard library.
| Approach | Measured time | Relative | Allocates? |
|---|---|---|---|
strcasecmp | about 2.3 ms | Fastest | No |
Manual loop with tolower | about 10 ms | 4x slower | No |
std::equal with a lambda | about 11 ms | 4x slower | No |
std::transform into two copies | about 59 ms | 26x slower | Yes, two per call |
Two conclusions. First, strcasecmp wins because glibc hand-optimizes it, so if you are in a hot loop on POSIX and can live with the #ifdef, it is the fastest thing available. Second, std::equal and the hand-written loop are the same speed, so there is no performance reason to write the loop yourself. The transform approach is an order of magnitude worse, entirely because of the allocations.
Troubleshooting
It gives the wrong answer on non-ASCII input. Expected, not a bug in your code. Byte-wise tolower cannot fold UTF-8 sequences, and no amount of casting fixes it. Either restrict the input to ASCII and document that, or bring in ICU.
Sanitizers or a code review flag undefined behavior. You passed a plain char to std::tolower. Cast every argument to unsigned char first, or declare your lambda parameters as unsigned char so the conversion happens automatically. On ARM, char is often unsigned by default, which is why the bug can hide on one target and appear on another.
The comparison always returns false for equal-looking strings. Check the sizes. A trailing \r from a CRLF file or a UTF-8 BOM makes one string one to three bytes longer with nothing visible in the debugger. That trap is covered in detail in storing text file data into a vector in C++.
string_view vs string confusion. Take std::string_view parameters, because std::string, const char* and literals all convert to it for free, whereas a const std::string& parameter allocates a temporary when you pass a literal. Just never store a string_view that outlives the buffer it points into, and never assume it is null-terminated, which rules out passing .data() to strcasecmp.
Different lengths crash or read past the end. The three-iterator std::equal(first1, last1, first2) overload assumes the second range is at least as long as the first, and reads out of bounds if it is not. Always guard it with a size check, use the four-iterator overload, or use std::ranges::equal, which checks for you.
Frequently asked questions
Does std::string have a case-insensitive compare in C++?
No. std::string::compare is strictly byte-wise, and there is no standard case-insensitive variant. You write a helper with std::equal or std::ranges::equal. Boost provides boost::iequals if you already depend on Boost, but it has the same ASCII limitation.
Why do I need to cast to unsigned char before calling tolower?
std::tolower takes an int that must be representable as unsigned char or equal EOF. Where char is signed, any byte above 0x7F becomes a negative int, which is outside that range and therefore undefined behavior. Glibc will index a lookup table with a negative offset.
Is strcasecmp portable across Windows and Linux?
No. strcasecmp is POSIX and lives in <strings.h>; MSVC provides _stricmp in <string.h> instead. Neither is in ISO C or C++. Either wrap them in an #ifdef _WIN32 block or use the standard std::equal version and skip the problem.
How do I make a case-insensitive std::map in C++?
Pass a comparator as the third template argument: std::map<std::string, V, CaseInsensitiveLess>, where the comparator wraps std::lexicographical_compare with a lowercasing predicate. Add using is_transparent = void; so lookups with a string_view or literal avoid building a temporary string.
Can I compare strings ignoring case with Unicode in C++?
Not with the standard library alone. Correct Unicode comparison needs case folding, which can change string length, plus normalization for composed and decomposed accents. Use ICU with a secondary-strength collator, or a dedicated Unicode library. Byte-wise tolower is correct for ASCII only.
What is the fastest way to compare two strings ignoring case?
Check lengths first, and return early on a mismatch. After that, strcasecmp was about four times faster than a portable loop in my measurement, because glibc optimizes it heavily. If you compare the same value repeatedly, normalize it to lowercase once and store the result rather than folding on every comparison.
Wrapping up
Write one iequals helper taking two std::string_view parameters, implement it with std::ranges::equal and an unsigned char lambda, put it in a header, and use it everywhere. It is portable, allocation-free, as fast as anything else in standard C++, and it makes the unsigned char cast impossible to forget. Add a matching iless for the day you need a map or a sort.
The part to be honest with yourself about is scope. If the strings are file extensions, HTTP header names, config keys or command flags, ASCII folding is genuinely correct and you are done. If they are user names, city names, search queries or anything typed by a human, it is not correct, and shipping it means shipping a bug that will be reported by a Turkish or German user and be hard to reproduce. Decide which situation you are in before you write the helper, not after. Reading names or keys out of a data file first? See storing text file data into a vector in C++, and for writing them back out, writing a file in C++.

