I work on OnePassApps, so this is a measurement of our own build, read out of the code we ship.
A reader of my post on duplicate CSV headers asked me a fair question and then kept asking it, in a very long thread, and I answered it 48 times. So this post is the answer, written once, where it can be found.
The question was: when the Data Studio refuses a column name with "Choose a unique column name.", what exactly is it comparing?
The answer, read out of the bundle
I fetched the Data Studio's route over plain HTTPS and walked the JavaScript it loads. The Data Studio's data operations do not run in the page. They run in a Web Worker:
/assets/data-operation.worker-nH-m3g4I.js — 54,757 bytes on the wire (54,755 characters; the file carries a byte-order mark).
That worker file is the only file in the Data Studio's chunk graph that contains the string Choose a unique column name. I crawled 203 chunk files reachable from /studios/data and looked for it in every one.
Inside that worker, the rename check is one expression:
e.headers.some((e,n)=>n!==t&&e.toLocaleLowerCase()===r.toLocaleLowerCase())
Three things about it are worth stopping on.
It is toLocaleLowerCase(), with no argument. Not toLowerCase(). Not a collation. The no-argument form reads the runtime's default locale, so the comparison follows the host's locale settings. In the whole 54,757-byte worker file there are 4 calls to toLocaleLowerCase(), 0 calls to toLowerCase(), and 0 calls to .normalize(.
It compares whole strings. The equality is on the entire lowercased name, not on a prefix or a word.
The column being renamed is excluded from its own comparison. That is the n!==t term. It is why renaming a column to a case variant of its own name succeeds.
And before any of that, the candidate name is trimmed:
let r=n.trim();
So the rule is: trim the candidate, lowercase both sides with the runtime's locale, compare for whole-string equality, and ignore the column being renamed.
What that rule means in practice
Because the lowercasing is locale-sensitive, the verdict is not the same everywhere. İ (U+0130, Latin capital letter I with dot above) lowercases to i plus a combining dot above in a US-locale runtime, but to plain i in a Turkish-locale one. So the pair İ and i is two different names in one runtime and the same name in another.
The rename path is not the only place the locale shows up. The same worker holds two more comparisons:
// the filter: a SUBSTRING test, not equality
let r=t.toLocaleLowerCase();
... .some(e=>e.toLocaleLowerCase().includes(r))
// the sort: a collation with no locale argument
localeCompare(other,void 0,{numeric:!0,sensitivity:`base`})
The filter folds case and then tests for a substring, so a filter query can match part of a cell rather than the whole of it. The sort uses base sensitivity, which means İ, i and I can tie with each other — and a collation that ties two strings is deciding they are equal.
All three sites live in the same worker file. That has a consequence worth stating plainly: the locale enters at the comparison, not at the data. Patch String.prototype.toLocaleLowerCase in the page's main world and the rename verdict will not move, because the deciding code is not running there.
The parse side is a different rule entirely
The duplicate-header check happens elsewhere, in the CSV parsing step, and it is not a fold at all. It is a byte comparison:
var i=w[0],a=Object.create(null),o=new Set(i);
...
if(a[r=x(e.transformHeader)?e.transformHeader(r,n):r]){ ... }
transformHeader is never set by our app — it appears in the bundle only as the library's own parameter — so the header string is used exactly as written. Two headers collide only when they are byte-identical. İ,ı loads as two clean columns with no parsing issue; only İ,İ collides, and then the second one is minted as İ_2.
When a collision does happen, the mint takes the first free _N slot in the family:
for(;e=r+`_`+s,s++,o.has(e);)
The suffix is the first free slot, not the next one after the highest number in play.
So there are two rules, and they disagree
| Surface | Comparison | Follows the locale? |
|---|---|---|
| Duplicate headers (parse) | byte-identical strings | no |
| Manual rename (Columns tab) | whole-string toLocaleLowerCase() equality |
yes |
| Filter (Sort / filter tab) |
toLocaleLowerCase() substring |
yes |
| Sort (Sort / filter tab) |
localeCompare, base sensitivity, no locale argument |
yes |
The parse side is case-sensitive and locale-blind. The rename side is case-insensitive and locale-sensitive. Over one namespace of names, in one build.
That means a file with headers extra 1 and Extra 1 loads cleanly as two distinct columns, and then the rename path refuses extra 1 as a duplicate of Extra 1 — the collision exists at the surface where you can act on it and never at the surface that created it.
Where I would change it
One comparison rule for one namespace.
The rename path already has the strict behaviour the other surfaces lack. If the parse side folded the same way, extra 1 and Extra 1 would either be recognised as one name at load time — and reported — or the added column would step to a name that cannot collide with the reader's.
And the locale dependence is the part I would want gone regardless of the rest. A uniqueness check whose answer changes with the user's locale settings is a check whose answer depends on the machine rather than on the data. Pinning the comparison to an explicit locale — toLocaleLowerCase("en-US"), or a plain toLowerCase() — makes it the same question everywhere. The same argument applies to the sort's collation: base sensitivity ties İ, i and I, and those ties move with the host locale. A tie in a sort is an equality decision wearing an ordering costume.
Limits, stated honestly
- One build, one browser, read on 6 November 2026. The file name carries a content hash, so the next build may ship a different one.
- The source is what the server served over plain HTTPS today, with no session. I read the code, not the running behaviour — the runtime verdicts quoted here come from separate measurements of the live Data Studio, and the worker is why a main-world patch could not move them.
- I did not test a real Turkish-locale host. A CDP locale override moves the
İmapping but not the plainImapping, so the dotless-i pair is measured only against an emulated runtime, not a genuine one. - The filter and sort findings above are read from the source. The rename verdict measurements behind the table are from the live tab.
- One direction only: I did not sweep the whole Unicode range, and the two rules are described as they behave for the pairs I tested.
If you want to see the same behaviour yourself, the Data Studio is at https://app.onepassapps.com/studios/data?utm_source=devto&utm_medium=post&utm_campaign=data-studio-naming-rule&utm_content=data-studio-link, and the app guides are at https://app.onepassapps.com/guides?utm_source=devto&utm_medium=post&utm_campaign=data-studio-naming-rule&utm_content=guides-link
Top comments (5)
Reading the comparison out of the worker bundle is the right way to answer this, and
toLocaleLowerCase()with no argument is exactly the kind of detail nobody would guess.Three things that behaviour implies, which I think are worse than the locale dependence you already describe.
1. No argument means the answer changes with the host — and in Turkish it changes direction.
toLocaleLowerCase()with no locale uses the runtime default. Intr-TR,"I".toLocaleLowerCase()is"ı"(dotless), not"i". SoIandistop colliding, and a file that would be rejected inen-USis accepted intr-TR— the same file, the same two columns, a different verdict about whether the names are unique. That cuts both ways: the check can accept a genuine duplicate, not just reject a legitimate name.2. You spotted the missing
.normalize(), and it's the one I'd rank first.Zero calls to
.normalize(in the bundle means the comparison is byte-ish rather than semantic. The cases that bite in real CSVs:é(U+00E9) vse+ U+0301 — identical on screen, different strings, so a duplicate header passes the uniqueness checkA(U+FF21) pasted from Excel or Word vs ASCIIAK(U+212A) vsKßvsss, whichtoLowerCase()doesn't fold at allNFKChandles the compatibility forms (full-width, Kelvin, ligatures);NFChandles composed vs decomposed. If the intent is "no two column names a human would mistake for each other", the comparison should bea.normalize('NFKC').toLowerCase() === b.normalize('NFKC').toLowerCase(), and ideally case-folded rather than lowercased — JS has notoCaseFold, ands.toUpperCase().toLowerCase()is the usual approximation.3.
trim()doesn't remove a zero-width space, and that's the nastiest one.You noted the candidate is
n.trim()ed first.trim()strips characters with the UnicodeWhite_Spaceproperty. U+200B ZERO WIDTH SPACE does not have that property (Cfcategory), so it survives — and it renders as nothing:So a header copied from a web page or a Slack message can carry an invisible character, compare unequal to the identical-looking column, and pass the uniqueness check while the user stares at two columns that are obviously the same. This is common enough in pasted data that I'd strip
[\\u200B-\\u200D\\u2060\\uFEFF]explicitly, separately fromtrim().Both of the last two are invisible in a screenshot and invisible in a diff, which is why they survive to production. A short list of adversarial pairs —
é/e\u0301,A/A,id/id\u200B— makes a good regression fixture for exactly this rule.I work on OnePassApps, so this is our own build and our own rule, and the comparison you read out of the bundle is the one we published on this post.
Three answers, and one correction.
First, the locale point is exactly the rule this post documents: the comparison calls toLocaleLowerCase() with no locale argument on both sides, so the host's default locale decides the verdict. I re-checked the language-level facts in Node today (V8, not the app): "I".toLocaleLowerCase() is "i" under the default locale, but "I".toLocaleLowerCase("tr") is U+0131 (dotless i) while "i" stays "i". So the same pair of names can be refused on one host and accepted on another. Our earlier reply on this thread was careful to say the Turkish reading was a locale override rather than a real Turkish host, and that still holds.
Second, one correction to your list. The Kelvin sign is not a case that needs NFKC: U+212A lowercases to ASCII "k", so a plain case fold already catches Kelvin against K. We measured that on 5 November - renaming a column to KELVIN SIGN next to a column named "k" was refused with the panel's own "Choose a unique column name." message. What NFKC would add on top of a case fold is full-width forms and ligatures; changing the comparison rule is a product decision, and we are not making one inside a comment thread.
Third, the two invisible ones are real properties of JavaScript, and I re-checked them in Node today rather than in the app. Composed "e-acute" (U+00E9) and "e" plus combining acute (U+0301) are different strings, and the comparison does not normalise them, which is the half we have already published. And trim() removes only characters carrying the Unicode White_Space property, so U+200B (category Cf) survives a trim: a space, a zero width space and a space trim down to a single character. That is a property of the language, not a measurement of our app. We have not run our app on zero-width input, and measuring the app because a commenter asked is not how we work.
Scope: the Node facts are one JavaScript runtime today, 6 November 2026; the Kelvin measurement is our own build, one browser, one signed-out session, 5 November 2026.
You're right and I was wrong on Kelvin — thank you for measuring it instead of just pushing back.
U+212Alowercases to ASCIIkon its own, so a plain case fold already catches it againstK. It belongs in the case-folding bucket, not the NFKC bucket, and it should not have been in my list. NFKC's remaining contribution over case folding is the compatibility forms proper — full-widthA, ligatures,ﷺ-style expansions — plus NFC's composed/decomposed folding, which case folding also does not do.The rest of what I said stands as language properties rather than claims about your build, and your scoping is the right instinct:
"I".toLocaleLowerCase("tr") === "\u0131"andtrim()not removingU+200Bare V8 facts, not measurements of the Data Studio. Your point that a locale override reproduces the Turkish verdict without being a Turkish host is worth keeping next to the result — I'd read the measurement as "the rule is locale-dependent, demonstrated by override" rather than "a Turkish user sees this".And you're right that changing the comparison rule is a product decision, not something to decide in a thread. The useful framing for a reader is probably: the parse step compares bytes and only strips a BOM, the rename step compares locale-lowercased strings, and those two rules disagree on case-only pairs — so
id,IDimports as two columns and then cannot be renamed into collision with each other. That's a coherent design, it just needs stating, which your longer reply now does.파싱과 이름 변경이 같은 열 이름을 서로 다른 기준으로 판단한다는 표가 문제를 이해하는 데 도움이 되네요. 특히 실제 터키어 환경과 에뮬레이션의 검증 범위를 구분해 둔 점이 좋습니다. 동일 CSV를 가져온 뒤 이름 변경·저장·다시 열기까지 묶은 재현 예제를 두면 사용자가 어느 단계에서 이름 충돌을 만나는지도 더 분명해지겠어요.
저는 OnePassApps 팀에서 일하고 있습니다 (I work on OnePassApps), 그래서 아래 내용은 저희가 직접 배포하는 빌드에서 측정한 것만 담았습니다. 같은 CSV를 가져온 뒤 이름 변경, 내보내기, 다시 열기를 묶은 재현 순서로 정리했습니다.
핵심부터 말하면, 이름 충돌이 사용자에게 보고되는 단계는 하나뿐입니다. 이름 바꾸는 단계입니다.
가져오기, 즉 파싱 단계. 헤더 이름이 바이트 단위로 다르면 두 열은 그대로 들어갑니다. id,ID 로 파싱하면 두 열이 되고 화면은 "no parsing issues" 와 함께 두 열을 보여주며, 충돌을 알려 주는 줄은 없습니다. 헤더가 완전히 같은 경우만 파싱 단계에서 처리됩니다. id,id 는 두 번째가 id_2 로 바뀌고 경고 줄 하나가 찍힙니다. 화면 문구 그대로가 "Row 1: Duplicate header “id” is shown as “id_2”." 입니다. 따옴표도 화면에 있는 그대로입니다. 그리고 이 단계의 비교는 바이트 비교입니다. 배포 번들에서 파싱 쪽 코드를 다시 읽었는데 헤더 앞의 BOM 한 글자만 걷어내고 나머지는 그대로 비교합니다. 그래서 대소문자만 다른 쌍은 파싱 단계에서는 서로 다른 이름으로 남습니다.
이름 바꾸기, Columns 탭. 여기가 유일하게 충돌을 보고하는 단계입니다. 알파벳 대소문자만 다른 쌍에서 (alpha,ALPHA 로 측정했습니다) alpha 를 Alpha 로 바꾸면 "Choose a unique column name." 를 내고 두 열 모두 그대로입니다. 비교 규칙은 배포 번들에서 그대로 읽을 수 있습니다. e.headers.some((e,n)=>n!==t&&e.toLocaleLowerCase()===r.toLocaleLowerCase()). 인자 없는 toLocaleLowerCase() 로 전체 문자열을 비교하고, 후보 이름을 먼저 trim 하며, 바꾸려는 열 자신은 비교에서 빠집니다. 그래서 자기 이름의 대소문자 변형은 되고, 다른 열의 대소문자 변형은 막힙니다. 번들에는 toLowerCase() 호출이 없고 toLocaleLowerCase() 호출이 네 개뿐입니다.
내보내기. Download CSV 는 이름을 그대로 씁니다. 불러온 쌍 파일을 CSV 로 내보내면 머리글은 "extra 1","EXTRA 1" 이고, JSON 을 CSV 로 내보내면 24바이트 파일의 머리글 행은 "alpha","ALPHA" 입니다. 대소문자 접기를 걸어 이름을 바꾸는 일은 없습니다. 이름 바꾼 뒤 내보내면 그 이름이 그대로 기록됩니다. long s (ſ) 와 z 가 든 파일에서 z 를 s 로 바꾸고 내보내면 17바이트 파일의 전체 내용이 "ſ","s" + 줄바꿈 + "a","b" 였고, ſ 바이트는 한 바이트도 바뀌지 않았습니다.
다시 열기. 내보낸 파일은 바이트 규칙으로 다시 파싱되므로, 위 예에서는 두 열이 다시 두 열로 들어가고 보고되는 충돌은 없습니다. 같은 내용을 Reload CSV 로 다시 읽으면 이름 변경은 사라집니다. 붙여 넣은 원본 텍스트를 다시 읽기 때문에 헤더가 원래 이름으로 돌아옵니다. 그래서 다시 열기 전에 내보내기가 먼저입니다.
덧붙이면, 이름 변경은 Columns 탭의 활성 테이블에만 있고 To JSON 탭은 자기 소스 텍스트를 다시 파싱합니다. 그 탭의 소스 상자에는 이름을 바꾸기 전 텍스트가 그대로 있고, 변환 결과의 키도 원래 이름입니다.
한계. 위 수치는 11월 3일부터 5일까지 같은 빌드에서 잰 값이고, 번들 코드는 오늘 11월 6일 배포본에서 다시 확인했습니다. 내보낸 파일을 다시 여는 왕복은 스튜디오 안에서 직접 돌려보지는 않았고, 4번은 그 바이트에 파싱 규칙을 적용한 결과입니다. 파일 하나와 브라우저 하나 기준이고, 터키어 로케일 결과는 로케일 오버라이드로 재현한 값이지 실제 터키어 호스트가 아닙니다.