re.sub can put the pieces it matched back into the replacement. Read the two boundaries — they are genuinely different shapes.
import re print(re.sub(r"(a)(b)", r"\1-\2", "ab")) # -> 'a-b' \1 is the first group # Boundary 1: a capital that begins a word, after anything # 'HTTPResponse' -> P then 'Response' # Boundary 2: a capital straight after a lowercase or a digit # 'userName' -> r then N # # One pattern cannot see both without also splitting HTTP into letters.
- \1 and \2 put the matched pieces back
The groups you captured are available in the replacement string, so you can insert something between them without knowing what they were. That is what makes 'put an underscore here' expressible at all.
- There are two different boundaries
In userName the boundary is lowercase-then-uppercase. In HTTPResponse it is uppercase-then-uppercase-followed-by-lowercase — the R starts a word only because 'esponse' follows it. Those are different patterns, which is why the usual solution runs two substitutions in order.
- Order matters between the passes
The acronym pass runs first, splitting HTTPResponse into HTTP_Response. The second pass then finds no lowercase-uppercase boundary inside HTTP and leaves it alone. Run them the other way round and the acronym is already ruined by the time the specific rule arrives.
Try writing one regular expression that puts an underscore before every capital, and run it on HTTPResponse. The result tells you why the answer has two steps rather than one.
First pass: find a character followed by a capital and a run of lowercase — that is the end of an acronym meeting a new word. Second pass: find a lowercase letter or digit followed by a capital. Lowercase everything at the end.
step = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name), then re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', step).lower(). The lowercasing goes last, once, rather than being woven into either pattern.
import re def camel_to_snake(name): step = re.sub(r'(.)([A-Z][a-z]+)', r'\____\_\____', name) return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', step).____()
import re
def camel_to_snake(name):
step = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", step).lower()
The first finds the end of an acronym — a capital followed by lowercase letters, meaning it starts a word rather than continuing an abbreviation. The second finds an ordinary lowercase-to-uppercase step. Trying to express both at once produces something nobody can read, and usually still gets acronyms wrong.
Both patterns are written against the original capitalisation, because that is the only signal for where the words are. Lowercase early and you have destroyed the information you were about to use. It is the mirror image of the title-case exercise, where normalising *first* was right — the rule is to normalise after you have read whatever the case is telling you.
([a-z0-9]) in the second pattern includes digits, so parseUTF8Text splits after the 8. Leaving digits out gives parse_utf8text, which is wrong in a way that no ordinary test would catch — most names people try do not have numbers in them.
If you are converting a whole API payload, do the conversion once at the boundary and keep snake_case everywhere inside — sprinkling conversions through the codebase means every function has to know which convention it was handed. Pydantic’s alias generators and libraries such as humps do this for entire nested structures, including the reverse direction. And note the conversion is lossy: userID and userId both become user_id, so round-tripping back to camelCase will not always return what you started with.
- Write snake_to_camel and check whether converting a name there and back gives you the original — then find one that does not.
- Convert every key of a nested dictionary, and decide what to do with keys that are not strings.
- Handle a leading underscore, as in _privateName, and decide whether it should survive.