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 — Front-end developer and tech writer at Allin
Web performance · File formats
Checked against 7 sources
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.
| Ecosystem | Variables and functions | Types and classes | Constants | What forces it |
|---|---|---|---|---|
| Python (PEP 8) | snake_case | CapWords | UPPER_SNAKE_CASE | Convention only; linters warn, the interpreter accepts anything |
| Rust | snake_case | UpperCamelCase | SCREAMING_SNAKE_CASE | The compiler: non_snake_case and non_camel_case_types warn by default |
| Go | mixedCaps | MixedCaps | MixedCaps, never underscores | The compiler: an initial capital is what makes an identifier exported, so case is semantics, not style |
| Java (Google Java Style) | lowerCamelCase | UpperCamelCase | UPPER_SNAKE_CASE | Convention, plus the explicit rule that acronyms are written as words: XmlHttpRequest, not XMLHTTPRequest |
| JavaScript and TypeScript | camelCase | PascalCase | UPPER_SNAKE_CASE | No official guide; the grammar only rules out the hyphen, because it is the minus operator |
| CSS and HTML | kebab-case for properties, classes and custom properties | CSS has no user-defined types; HTML element names are lowercase | kebab-case custom properties, prefixed with two hyphens | The 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 |
| PostgreSQL | snake_case for tables and columns | snake_case for types and domains | UPPER_SNAKE_CASE by convention only | The parser: unquoted identifiers are folded to lower case, so a camelCase table name silently becomes lowercase unless you double-quote it forever |
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 →Related tools
Sources
- Python Software Foundation — PEP 8: Style Guide for Python Code - Naming Conventions
- Google — Google Java Style Guide, section 5.3: Camel case: defined
- The Go Authors — Effective Go: Names (MixedCaps, and the initial capital as the export rule)
- The Rust Project — Rust API Guidelines: Naming
- PostgreSQL Global Development Group — PostgreSQL Documentation: Lexical Structure - Identifiers and Key Words
- W3C — CSS Values and Units Module Level 4 (whitespace required around plus and minus in calc())
- Google Search Central — URL structure best practices for Google
Spotted a mistake in this article?