Skip to content
Allin

camelCase, snake_case, kebab-case: Which One, and Why You Rarely Get to Choose

Published 7/3/2026 · 12 min read · Text & language tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 7 sources

View profile
In short

Each convention is forced by what the surrounding syntax allows, not chosen for looks. In almost every infix language the hyphen is the subtraction operator, so user-name parses as user minus name and cannot be an identifier - JavaScript raises a SyntaxError on var user-name. That single fact splits the world. CSS property names, HTML attributes and URL paths sit in grammars where identifiers are never expressions, so a hyphen is unambiguous there and kebab-case is the native style. Lisp-family languages allow kebab identifiers for the same reason: they are prefix, not infix. Everything else settles on snake_case or camelCase, and the choice is per ecosystem: PEP 8 mandates snake_case for Python functions and variables; Rust does the same and its compiler warns by default; Go mandates MixedCaps and makes the initial capital semantic, since it controls export; Java and JavaScript use lowerCamelCase with PascalCase types. The trap is the round trip. Converting camelCase to snake_case and back is not lossless when acronyms appear: parseHTMLDocument becomes parse_htmldocument becomes parseHtmldocument under a naive converter, and the word boundary is gone forever. Fix it at the source by treating acronyms as ordinary words, as the Google Java Style Guide requires. And a URL slug is a fourth thing: lowercased, accent-stripped and length-capped.

The conventions are not taste. A hyphen is the minus operator, so kebab-case cannot be an identifier in most languages - which is exactly why CSS and URLs use it. Plus the acronym round-trip that silently corrupts names, and the rule that fixes it.

The hyphen is the minus operator, and that is the whole explanation

Type var user-name = 1 into Node and you get SyntaxError: Unexpected token '-'. The parser is not being difficult. In an infix language, a-b in expression position means subtract b from a, and there is no way for the tokeniser to know that you meant one identifier rather than two operands. Python, Java, C, C#, Go, Rust, PHP, Ruby and SQL all share this constraint. The hyphen is spoken for.

That leaves exactly two ways to join words inside an identifier: an underscore, which no language uses as an operator, or a capital letter, which is not a character in the token-separator sense at all. snake_case and camelCase are not two aesthetic schools. They are the only two solutions to a constraint imposed by arithmetic.

The exception proves the rule. Lisp, Scheme, Clojure and Common Lisp all allow kebab-case identifiers - make-hash-table, my-function-name - because they are prefix languages: subtraction is written (- a b), so a hyphen between letters can never be an operator. Change the grammar and the naming convention changes with it, which is exactly the point.

Where kebab-case is native: CSS, HTML attributes, URLs

In CSS, background-color is a property name in a position where no expression is allowed, so the hyphen has nothing to be confused with. The proof that CSS is aware of the tension is calc(): the specification requires whitespace around the plus and minus signs inside it, precisely because that is the one place in CSS where a hyphen could be either a subtraction or part of an identifier. HTML attributes follow the same logic: data-user-id is a name in attribute position, never an expression.

The crossing point is where it gets interesting. The DOM has to expose data-user-id to JavaScript, where the hyphen is illegal, so it renames it: element.dataset.userId. The CSS Object Model does the same thing to properties, turning background-color into style.backgroundColor. Those two automatic conversions are the clearest possible demonstration that the convention is a function of the host grammar and nothing else - the same name, spelled two ways, because two grammars demand two spellings.

URLs allow both the hyphen and the underscore - both are unreserved characters - so here the reason is different and much softer. Google's own URL guidance recommends hyphens over underscores because they read as word separators to both crawlers and people, and because an underscore can disappear under the underline of a link. That is a readability argument, not a grammatical one, but it has hardened into a convention strong enough that a URL with underscores now looks like a mistake.

The round trip that loses information

Converting camelCase to snake_case and back looks like a bijection. It is not, and acronyms are where it breaks. A naive converter inserts an underscore before each uppercase letter that follows a lowercase one, then lowercases everything. Run parseHTMLDocument through it and you get parse_htmldocument, because there is no lowercase letter before the H, the T, the M or the L. Convert back and you get parseHtmldocument. The word boundary between HTML and Document is gone, and no amount of cleverness downstream can recover it.

getIDFromURL is worse, because the damage is not confined to the casing. The naive rule produces get_idfrom_url - it fires between the t and the I, and again between the m and the U, but not inside IDFrom - and converting back yields getIdfromUrl. A name that was three clear words has become two mangled ones, and if that string is a database column, a JSON key or an API field, the corruption is now persisted.

The rule that fixes it, and the case it still misses

The fix is a second boundary rule. Alongside the usual lowercase-then-uppercase split, add a split between a run of capitals and a capital followed by a lowercase letter. In regular-expression terms that is two passes: insert an underscore between ([a-z0-9]) and ([A-Z]), then between ([A-Z]+) and ([A-Z][a-z]), then lowercase. With those two rules, parseHTMLDocument becomes parse_html_document and comes back as parseHtmlDocument; getIDFromURL becomes get_id_from_url and comes back as getIdFromUrl; exportToPDFFile becomes export_to_pdf_file. The kebab forms are parse-html-document, get-id-from-url and export-to-pdf-file. The word boundaries survive.

Note that the round trip is still not the identity: parseHTMLDocument comes back as parseHtmlDocument, with the acronym title-cased. That is the correct outcome, not a residual bug, and it points at the real fix. Section 5.3 of the Google Java Style Guide mandates exactly this at authoring time: write acronyms as ordinary words, so XmlHttpRequest rather than XMLHTTPRequest, and the name becomes a fixed point of the conversion. A name that survives its own round trip is a name you can safely put through a code generator, an ORM, a serialiser and back.

There is one case the two-regex rule still gets wrong, and it is worth knowing because it looks like the rule failing. Mixed-case acronyms defeat it: supportsIPv6 becomes supports_i_pv6, and the kebab form is supports-i-pv6. The second regex sees the capital P followed by the lowercase v and splits there, which is exactly what it is supposed to do everywhere else. No boundary rule that only looks at letter case can know that IPv6 is one token. This is the strongest argument for the Google rule: write supportsIpv6 in the first place and the converter never has to guess.

A URL slug is a fourth thing, not kebab-case with extra steps

A slug looks like kebab-case but has three extra obligations that an identifier never has. It must survive lowercasing, because URL paths are compared case-sensitively by servers but typed carelessly by humans. It must survive accent stripping, because a path with accented characters gets percent-encoded and becomes unreadable. And it must fit a length budget, because slugs end up in emails, printed material and address bars where a 200-character path is unusable.

The accent-stripping step is where naive implementations lose data silently. The usual recipe is to normalise to decomposed form, delete the combining marks, then keep only letters, digits and hyphens. Applied to a French title it works: Crème Brûlée & Co. — 2026 Edition! becomes creme-brulee-co-2026-edition, 28 characters. Applied to German it destroys text. The title Größe & Maße: der Überblick comes out as gro-e-ma-e-der-uberblick - because the sharp s has no canonical decomposition, so it is not folded to anything, it is simply deleted along with every other non-Latin-letter character.

The fix is to transliterate before you normalise, using a per-language map: the sharp s to ss, the umlauted vowels to oe, ae and ue in German, the Scandinavian slashed o and ring a to their two-letter equivalents. With that step in front, the same German title yields groesse-masse-der-ueberblick, which is 28 characters and actually readable. Keep the resulting slug immutable once published, cap it at something like 60 to 80 characters at a word boundary, and never regenerate it from an edited title without issuing a redirect from the old one.

Choosing, in practice

Follow the host, not your preference. Inside a Python file, snake_case, even if the JSON you are parsing is camelCase. Inside a CSS file, kebab-case, even if the design tokens were authored in camelCase. Inside a PostgreSQL schema, snake_case, because the parser will lowercase your camelCase anyway and you will spend the rest of the project writing double quotes.

Convert only at boundaries, and convert in one place. If your API speaks camelCase and your database speaks snake_case, put a single mapping layer between them rather than converting ad hoc at each call site, and make that layer the only code that knows the two-regex rule. Write acronyms as words everywhere, so the conversion is a fixed point and nobody has to think about it again. And when you generate a slug, treat it as a published identifier from the moment it ships: it is the only one of these four forms that a stranger will paste into a message.

What each ecosystem's own style guide mandates, and what actually forces it - convention, a linter, or the parser itself.
EcosystemVariables and functionsTypes and classesConstantsWhat forces it
Python (PEP 8)snake_caseCapWordsUPPER_SNAKE_CASEConvention only; linters warn, the interpreter accepts anything
Rustsnake_caseUpperCamelCaseSCREAMING_SNAKE_CASEThe compiler: non_snake_case and non_camel_case_types warn by default
GomixedCapsMixedCapsMixedCaps, never underscoresThe compiler: an initial capital is what makes an identifier exported, so case is semantics, not style
Java (Google Java Style)lowerCamelCaseUpperCamelCaseUPPER_SNAKE_CASEConvention, plus the explicit rule that acronyms are written as words: XmlHttpRequest, not XMLHTTPRequest
JavaScript and TypeScriptcamelCasePascalCaseUPPER_SNAKE_CASENo official guide; the grammar only rules out the hyphen, because it is the minus operator
CSS and HTMLkebab-case for properties, classes and custom propertiesCSS has no user-defined types; HTML element names are lowercasekebab-case custom properties, prefixed with two hyphensThe grammar: a property name is never an expression, so a hyphen inside it cannot be a minus - which is why calc() requires whitespace around its minus signs
PostgreSQLsnake_case for tables and columnssnake_case for types and domainsUPPER_SNAKE_CASE by convention onlyThe parser: unquoted identifiers are folded to lower case, so a camelCase table name silently becomes lowercase unless you double-quote it forever
kebab-case converterConvert any text or camelCase identifier into lower kebab-case (hyphenated).Try the tool

Frequently asked questions

Why can I write background-color in CSS but not backgroundColor in a stylesheet?
Because CSS property names are a fixed vocabulary defined by the specification, and the specification spells them in kebab-case. It is not that camelCase is illegal in the grammar - it is that backgroundColor is simply not the name of any property, so the declaration is dropped as unknown. The camelCase spellings exist only in the CSS Object Model, the JavaScript view of a style, where the hyphen would be a minus sign. Two spellings, two grammars, one property.
Is camelCase or snake_case easier to read?
The published eye-tracking work on this is small, dated and contested, and it does not support a strong claim either way - some studies find snake_case marginally faster to read, others find trained readers faster in whatever style they use daily. What is not contested is the cost of inconsistency inside one codebase. Pick whatever the ecosystem mandates, enforce it with a formatter, and spend the argument budget on something that changes behaviour.
How do I convert an API's camelCase JSON to a snake_case database safely?
Use the two-pass boundary rule, apply it in exactly one module, and pin the mapping for any name that contains an acronym. The pragmatic version is to keep an explicit override table: a short dictionary of the fifteen or twenty field names in your schema whose conversion you do not want a regex deciding. That table costs an hour to write and removes the entire class of bug, whereas a purely algorithmic mapping will eventually meet a name like supportsIPv6 and produce something no reviewer notices until a query returns nothing.
Can I use camelCase for PostgreSQL table and column names?
You can, but only by double-quoting the identifier every single time it appears, forever, in every query, migration, view and script. PostgreSQL folds unquoted identifiers to lower case, so a table created as userAccounts becomes useraccounts, and a later query for userAccounts finds it only because that also folds to useraccounts - until the day someone quotes one of them and the two stop matching. snake_case is not a style preference here; it is the shape that survives the parser untouched.
Should a URL slug just be the title in kebab-case?
Almost, but with three additions that kebab-case alone does not give you. Lowercase everything, because a path that differs only by case is a different resource to a server but the same thing to a person. Transliterate before you strip accents, or characters like the German sharp s vanish entirely instead of folding to ss. And cap the length at a word boundary, around 60 to 80 characters, since the slug will be pasted into places with no room. One more rule that has nothing to do with casing: once published, never change it without a permanent redirect from the old path.

Articles you may find interesting

All guides
ExplainercamelCase vs snake_case: A Guide to Naming Conventions in CodecamelCase, snake_case, PascalCase, and kebab-case explained: what each looks like, where it is the convention, and how to choose one consistently.GuideURL Encoding Explained: Percent-Encoding and Where It BitesPercent-encoding is decided per URL component, which is the whole source of the confusion. A slash is legal in a path and must be escaped in a query value; a space is %20 in a path and may be + in a form body. Here are the exact RFC 3986 sets, the three JavaScript functions that disagree, and the traps.ExplainerThe Caesar Cipher Explained: How Shift Ciphers and ROT13 WorkThe Caesar cipher shifts every letter by a fixed amount. Learn how the shift works, why ROT13 is a special case, how to encode and decode by hand, and why the cipher offers no real security today.How-toHow to Convert JSON to CSV: Flattening Arrays of Objects into Rows and ColumnsA practical guide to turning a JSON array of objects into a clean CSV file, including how to flatten nested fields and handle the tricky edge cases.ExplainerRemoving Accents Breaks Search — Until You Do It on Both SidesFolding diacritics is a normalisation step, and normalisation only works when the same function runs on the index and on the query. NFC against NFD with the code points shown, and the letters — ø, ł, ß, œ, ı — that survive the strip untouched.ExplainerHashtags Are a Search Index, Not a MegaphoneA hashtag makes a post findable in a query, which is a different job from making it spread. Model the visibility a tag actually buys and the answer falls out: a tag's worth is its viewers-per-post-published, not its volume — so a very popular tag returns almost nothing and a specific one returns twenty-five times more.

Related tools

Sources

Spotted a mistake in this article?