Skip to main content

Text Case Converter

Convert between camelCase, snake_case, kebab-case, PascalCase, and more in one paste.

Case Options

Convert an identifier between camelCase, PascalCase, snake_case, kebab-case, SCREAMING_SNAKE and the rest. Conversion is really two operations — split the input into words, then rejoin them — and nearly every bug lives in the first one, because the original word boundaries are not recoverable from the string alone.

Acronyms are where the splitter gives up

The naive rule for camelCase is to start a new word at every uppercase letter. That turns HTTPServer into h_t_t_p_server, IOError into i_o_error, and parseXMLHTTPRequest into something no reviewer will accept.

The standard repair is a lookahead: split before an uppercase letter only when it is followed by a lowercase one, so a run of capitals stays intact. That produces http_server and io_error correctly. It also produces parse_xmlhttp_request, because nothing in the string indicates where XML ends and HTTP begins. The information was destroyed when the identifier was written, and no algorithm recovers it.

Language style guides deal with this by banning capital runs at the source, and they disagree about how. Google's Java style requires acronyms to be written as ordinary words — XmlHttpRequest rather than XMLHTTPRequest, and supportsIpv6OnIos rather than supportsIPv6OnIOS. Rust's RFC 430 conventions say the same thing, which is why the standard library spells it Uuid and not UUID.

Microsoft's .NET capitalization guidelines use a length rule instead: two-letter acronyms keep both capitals, giving IOStream and the System.IO namespace, while three letters or more take Pascal case, giving XmlWriter and HtmlButton. Go inverts the whole idea and keeps initialisms fully cased, so it is ServeHTTP and userID, never ServeHttp or userId. There is no universally correct conversion here, only a convention you pick and enforce — and if you are generating code, the acronym list belongs in the generator rather than in the splitter's guesswork.

Round-tripping an identifier is not an identity operation

Convert IOError to snake_case and you get io_error. Convert that back to PascalCase and you get IoError. Those are two different symbols and only one of them exists in your codebase.

This matters wherever conversion happens in both directions: an ORM mapping columns to fields, a client generated from an OpenAPI schema, a middleware camelising server keys on the way out and snake-casing them on the way back in. The failure surfaces at runtime as a property that is undefined, not at build time as a type error, and it surfaces only for the handful of names that contain an acronym.

Several systems then fold case on your behalf, which removes the choice entirely. PostgreSQL lowercases every unquoted identifier, so CREATE TABLE t (userId int) creates a column literally named userid, and a later query against "userId" fails with "column does not exist". Oracle folds unquoted identifiers to uppercase instead, so the same schema script produces different names on the two engines. HTTP/2 requires header field names to be transmitted in lowercase, so a service that compares against Content-Type with an exact string match works over HTTP/1.1 and breaks the moment the connection is upgraded.

The filesystem joins in as well. A default macOS APFS volume is case-insensitive but case-preserving, so renaming Button.tsx to button.tsx is invisible to git status unless you use git mv — and the missing-import error then appears only in CI, on a case-sensitive Linux filesystem. The defence in all of these cases is the same: keep one canonical form, derive wire and display formats outward from it, and never derive the canonical form back from a converted one.

Where a word begins when the input contains digits

Digits have no case, so a splitter must decide whether a digit starts a word, ends one, or belongs to its neighbour. Libraries answer differently and none of them is wrong.

utf8Decoder can reasonably split as utf8 / decoder or utf / 8 / decoder, giving utf8_decoder or utf_8_decoder. s3Bucket becomes s3_bucket or s_3_bucket. parseInt32Value becomes parse_int32_value or parse_int_32_value. OAuth2Client and Base64Encoder combine the digit problem with the acronym problem and can produce four different outputs across four libraries. Check your specific library against your specific identifiers before you run a migration on them; the behaviour is not predictable from the function name.

The other boundary hazards are easier to reason about and just as common in real input: leading and trailing separators (does the underscore in _private survive?), repeated separators (a__b), mixed separators in one string (get-userName_id), and input that is already in the target format. A converter that is not idempotent will quietly mangle already_snake on a second pass, which is exactly what happens when a transformation runs twice in a pipeline.

Single-word all-caps input is genuinely ambiguous rather than merely awkward. ID could be one word or two initials, and URL standing alone has no following lowercase letter to anchor the acronym lookahead, so most splitters emit url and most rejoins hand back Url. Empty strings and single characters deserve a test too, since a surprising number of hand-rolled converters index position zero without checking.

The Turkish dotless i, and why toUpperCase has a locale

Turkish and Azeri treat dotted and dotless i as separate letters. Uppercasing i yields İ (U+0130, capital I with a dot above) and lowercasing I yields ı (U+0131, dotless small i). Both mappings are correct in those locales and both break any comparison that assumed ASCII.

Whether it reaches you depends on the language you are writing. JavaScript's toUpperCase and toLowerCase are specified as locale-independent, so they always produce I and i; the Turkish behaviour only appears if you explicitly call toLocaleUpperCase("tr"). Python's str.upper() is likewise locale-independent.

Java is not, and this is the classic version of the bug. String.toUpperCase() with no argument uses the JVM's default locale, so identical code produces TITLE on a developer laptop and TİTLE on a server whose locale is tr-TR. Comparing that against a hardcoded literal fails, and the failure reproduces in exactly one region — which is why static analysers flag the no-argument overload and why toUpperCase(Locale.ROOT) is the correct call. C# splits the same way: ToUpper() follows CurrentCulture while ToUpperInvariant() does not.

The deeper fix is to stop case-folding for the purpose of comparison at all. Use a comparison that understands case rather than normalising both sides first: localeCompare with sensitivity: "accent" in JavaScript, StringComparison.OrdinalIgnoreCase in .NET, or full Unicode case folding where you need it. Lowercasing two strings and testing them with === is the pattern that fails, and it fails on user data rather than on your test fixtures.

One character in, two characters out

Unicode case mapping is not a one-to-one function over code points, so s.toUpperCase().length is not reliably s.length. ß uppercases to SS, one code point becoming two. The ligature fi (U+FB01) uppercases to FI. ǰ (U+01F0) uppercases to a J plus a combining caron. Do not size a buffer from the input length, and do not assume an index into the original string still points at the same character afterwards.

It is also not reversible. "straße".toUpperCase() is STRASSE, and lowercasing that gives strasse — the ß is gone for good. Any system that keeps an uppercased copy of a name for searching has permanently destroyed the distinction between Masse and Maße, which are different German words.

Some mappings depend on position rather than on locale. Greek capital sigma lowercases to ς at the end of a word and σ everywhere else, which means lowercasing is not compositional: lowercase a word in one pass and you get one answer, lowercase it in two halves and concatenate and you get another. Anything doing chunked or streaming case conversion has to buffer across the boundary.

Unicode also defines three cases, not two. Digraphs such as dz have a distinct titlecase form Dz that differs from the uppercase DZ, which is why the standard specifies Titlecase_Mapping separately from Uppercase_Mapping. For identifiers you authored yourself, all of this is academic. For names, addresses and search input it is not, and the cost of ignoring it is data you cannot reconstruct.

ThenCatch is a free developer toolkit. Learn more about us.