iKit
Guide · 10 min read ·

camelCase to snake_case: Convert Without Breaking Code (2026)

A practical guide to converting camelCase to snake_case: the regex that works, the acronym rule that breaks it, and why round-tripping loses information.

camelCase to snake_case: Convert Without Breaking Code (2026)

camelCase to snake_case: How to Convert Without Breaking Your Code

Every codebase eventually needs this: a JavaScript client speaks camelCase, the Python service speaks snake_case, and the Postgres table in the middle speaks neither until you tell it to. Converting camelCase to snake_case looks like a one-line regex until an acronym like XMLHTTPRequest walks in. Here is what actually works, and what silently loses information.

TL;DR

  • Split at every lower-to-upper boundary, then lowercase: that covers 90% of identifiers.
  • Runs of capitals (XMLHTTPRequest) need a second regex pass or they collapse wrong.
  • Conversion is lossy: userId, userID and user_id all map to one string.
  • Postgres folds unquoted identifiers to lower case, so snake_case columns are safest.
  • Pick one canonical form per boundary and generate the other; never convert both ways.

What camelCase and snake_case actually are

Both are conventions for gluing multi-word names into single identifiers, since most languages forbid spaces in names. They differ only in what marks the word boundary: a capital letter, or an underscore.

camelCase vs PascalCase — the one-character difference

lowerCamelCase starts with a lowercase letter (totalItemCount). UpperCamelCase, usually called PascalCase, capitalises the first word too (TotalItemCount). MDN's glossary treats both as camel case; most style guides split them by role, reserving PascalCase for types and classes and lowerCamelCase for variables and methods.

The distinction matters for conversion because it changes where the first boundary falls. TotalItemCount has a leading capital that is not a word separator — a naive regex will emit a leading underscore (_total_item_count) if you do not strip it.

Why snake_case exists at all

Underscores predate case-sensitive identifiers in a lot of tooling. They are also unambiguous: user_id has exactly one reading, whereas userId needs a rule to tell you whether the boundary is before I or somewhere else entirely. That unambiguity is why snake_case dominates in Python and in SQL, and why file paths and URLs lean on separators rather than capitalisation.

PEP 8 puts it plainly for Python: function names should be lowercase with words separated by underscores as needed for readability. The Rust API Guidelines draw the same line differently — UpperCamelCase for type-level constructs, snake_case for value-level ones — and add a rule most converters get wrong: in snake_case a "word" should never be a single letter unless it is the last word, which is why the standard library has btree_map rather than b_tree_map.

Which convention each ecosystem expects

Context Convention Example
Python functions, variables snake_case parse_user_id
Rust functions, fields snake_case is_xid_start
Java methods, fields lowerCamelCase sendMessage
JavaScript variables lowerCamelCase totalItems
JSON API properties lowerCamelCase itemsPerPage
SQL columns snake_case created_at
CSS classes, URLs kebab-case nav-item
Env vars, constants SCREAMING_SNAKE DATABASE_URL

Google's JSON Style Guide is explicit about the API row: property names must be camel-cased ASCII strings, specifically so JavaScript clients can reach them with dot notation instead of bracket syntax.

How to convert camelCase to snake_case

The regex that handles the simple cases

One substitution, one lowercase call:

const toSnake = (s) =>
  s.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
   .toLowerCase();

toSnake('totalItemCount'); // total_item_count
toSnake('userId');         // user_id
toSnake('parse2Files');    // parse2_files

The capture groups matter: you are matching a two-character boundary and rebuilding it with an underscore between, not deleting anything. If capture-group syntax is unfamiliar, our walkthrough of regex capture groups covers $1 and named references.

Python's version is the same idea:

import re

def to_snake(s: str) -> str:
    s = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s)
    return s.lower()

to_snake('totalItemCount')  # total_item_count

Why XMLHttpRequest becomes xml_http_request

Run XMLHTTPRequest through the one-liner and you get xmlhttprequest — a single word. There are no lower-to-upper boundaries inside a run of capitals, so the regex finds nothing to split.

The fix is a second pass that splits an acronym from the word that follows it, run before the first:

ACRONYM = r'([A-Z]+)([A-Z][a-z])'
BOUNDARY = r'([a-z0-9])([A-Z])'

def to_snake(s):
    s = re.sub(ACRONYM, r'\1_\2', s)
    s = re.sub(BOUNDARY, r'\1_\2', s)
    return s.lower()

to_snake('XMLHTTPRequest')  # xmlhttp_request  ← still wrong
to_snake('XmlHttpRequest')  # xml_http_request ← correct

Note the result on the first line. XMLHTTPRequest contains two adjacent acronyms and the pattern can only find the last boundary, because nothing in the string marks where XML ends and HTTP begins. No converter can recover that — the information was destroyed when the identifier was written.

Which is exactly why the Google Java Style Guide specifies a deterministic camel-case scheme in §5.3: lowercase everything including acronyms, then uppercase only the first character of each word. Its own table gives XmlHttpRequest as correct and XMLHTTPRequest as incorrect, along with newCustomerId over newCustomerID and supportsIpv6OnIos over supportsIPv6OnIOS. Follow that rule when you write identifiers and conversion stays mechanical forever.

Converting every key in a JSON object

Renaming one string is easy; renaming a payload is where people reach for a script:

const deepSnake = (v) =>
  Array.isArray(v) ? v.map(deepSnake)
  : v && typeof v === 'object'
    ? Object.fromEntries(
        Object.entries(v).map(
          ([k, val]) => [toSnake(k), deepSnake(val)]
        ))
    : v;

Two cautions. Recursing into every object also renames map keys that were never identifiers — a thumbnails object keyed by pixel width, say. And it renames keys inside user-supplied blobs, which is a data-corruption bug rather than a style fix. Paste the payload into the JSON decoder first and look at the shape before you write the recursion.

Why does Postgres lowercase my camelCase column names?

Because it is supposed to. This surprises people migrating an ORM-generated schema, and it is the strongest practical argument for snake_case in the database.

Unquoted identifiers get folded

Per the PostgreSQL documentation on lexical structure, unquoted names are always folded to lower case. So FOO, foo and "foo" are the same identifier, while "Foo" and "FOO" are distinct from those three and from each other.

CREATE TABLE users (userId int);
SELECT userId FROM users;   -- works: folds to userid
SELECT "userId" FROM users; -- ERROR: column does not exist

The column is stored as userid. Your ORM, which quotes identifiers, then asks for "userId" and gets nothing.

The portability trap in quoting your way out

You can keep the casing by double-quoting the identifier everywhere — every migration, every hand-written query, every index definition, forever. The Postgres docs note that this folding behaviour is itself incompatible with the SQL standard, which folds unquoted names to upper case, and advise that portable applications should either always quote a given name or never quote it. Mixed-case columns commit you to "always", across every tool that ever touches the database. snake_case commits you to nothing: it survives both foldings unchanged.

Bridging a snake_case backend and a camelCase frontend

Three places to put the translation, in descending order of how much I like them:

  • At the serialisation layer. One mapper, applied once, in the code that turns rows into JSON. Django REST Framework, Laravel API resources and Jackson's PropertyNamingStrategies.SNAKE_CASE all do this declaratively.
  • At the HTTP client. A response interceptor that converts keys on the way in. Works when you cannot change the server, but every developer must remember it exists.
  • By hand, per field. Fine for ten fields, a liability at a hundred, and the source of the classic bug where one endpoint returns created_at and another createdAt.

Whichever you pick, pick exactly one. Two conversion points means a payload can be converted twice, and user_id → userId → user_id looks harmless right up until an acronym is involved.

Mistakes that bite during a bulk rename

Acronyms, digits, and single-letter words

The three inputs worth testing before you trust any converter:

Input Naive result Correct
parseXML parse_x_m_l parse_xml
bTreeMap b_tree_map btree_map
turnOn2sv turn_on2sv turn_on_2sv
IPv6Address i_pv6_address ipv6_address

The bTreeMap row is the Rust single-letter rule from earlier; the turnOn2sv row is Google's, whose table also gives turnOn2sv as correct against turnOn2Sv. Reasonable guides disagree here, which is the real lesson: pick one and encode it in a linter rather than trusting each developer's regex.

Round-tripping loses information

snake_case has no capitalisation to preserve, so the mapping back is a guess:

userId    → user_id → userId   ✓
userID    → user_id → userId   ✗ changed
APIKey    → api_key → apiKey   ✗ changed
ApiKey    → api_key → apiKey   ✗ changed

Three distinct camelCase identifiers collapse onto one snake_case string, and the reverse conversion has to pick a winner. If a system converts in both directions — say, camelCase request bodies converted to snake_case for the database and back for the response — it is not round-tripping, it is normalising. Usually that is fine, occasionally it renames a field nobody expected. Diffing the before and after payloads is the cheap check; a plain text diff on two pretty-printed JSON blobs will show it immediately.

Do not rename across a published boundary

Internal variables are free to rename. Anything a consumer can see is not: JSON response keys, CSV column headers, query-string parameters, environment variable names. Renaming apiKey to api_key in a response body is a breaking change even though the values are identical.

If a rename has to cross that line, emit both keys for a deprecation window and remove the old one on a version boundary. The same applies to data at rest — column headers in an exported CSV are an interface too, and normalising them silently breaks whatever script consumes the file. When you are reshaping tabular data anyway, CSV ↔ JSON conversion is a good place to see both header sets side by side before committing.

For one-off conversions — a column list, a set of API fields, a config block you are porting between languages — a browser case converter beats writing the regex again. Paste, convert, check the acronyms by eye, move on. For a codebase-wide rename, use your editor's regex find-and-replace with review enabled; our guide to regex find and replace in VS Code covers the capture-group syntax for that.

References

Related on iKit

Related posts