
I was building a CSV viewer and I needed a test for one question: would a spreadsheet change this value on the way in?
I started the way everybody starts, with a list of patterns. Leading zeros. Long ids. Prices ending in a zero. Things that look like dates. The list kept growing, every entry needed its own regular expression, and none of them agreed with each other about the edges.
Then it turned out the test is one line.
String(Number(value)) !== valueIf a value doesn't survive a trip through Number and back to a string, then reading it as a number loses something. It doesn't matter what. The arithmetic answers a question the patterns were only guessing at.
One Line That Finds All of Them
Run it on the values people actually lose and it catches every one, with no list to maintain.
| Value | Number(value) | Back to a string | Survives |
|---|---|---|---|
42 | 42 | 42 | yes |
07030 | 7030 | 7030 | no |
7203415887654321987 | 7203415887654322000 | 7203415887654322000 | no |
1.50 | 1.5 | 1.5 | no |
1E5 | 100000 | 100000 | no |
That is a zip code, an order id, a price and a value from a scientific instrument. Four different kinds of damage, one test.
I still keep a small amount of pattern matching, but only to name the problem after the arithmetic has found it, so the page can say "leading zero" rather than "this changed". The finding is the division. The naming is cosmetic.
What a Spreadsheet Actually Does, in Its Own Words
Microsoft documents both halves of this, which I didn't expect.
On long numbers, their support page is direct. Excel "has a maximum precision of 15 significant digits", and for anything longer:
any numbers past the 15th digit are rounded down to zero
Their own example is 12345678901234567890 becoming 12345678901234500000. A 20 digit id comes back as a 20 digit id with the last five digits replaced by zeros, which is worse than an error, because it still looks like an id.
On leading zeros there is a setting under File, then Options, then Data, called "Remove leading zeros and convert to a number". It's on by default, and turning it off is what makes 00123 stay 00123. The switch is only in Microsoft 365 and Excel 2024, so on an older Excel you get the behavior without the option to stop it.
So the behavior is documented, deliberate, and switched on for everybody who hasn't gone looking for that dialog.
The Genes
The most expensive version of this is not a zip code.
Excel reads SEPT2 as the second of September and MARCH1 as the first of March. Those are human gene symbols. In 2016 Ziemann, Eren and El-Osta scanned 35,175 supplementary Excel files from 18 genomics journals and found gene name errors in 19.6 percent of the papers that shipped a gene list (Genome Biology 17:177).
One in five papers, in the supplementary data, silently.
The ending is the part I keep thinking about. In its 2020 guidelines the HUGO Gene Nomenclature Committee added "symbols that affect data handling and retrieval" as grounds for renaming a gene, and changed every human symbol that auto-converted to a date. MARCH1 is MARCHF1 now. SEPT1 is SEPTIN1. Biology changed its names because a spreadsheet wouldn't change its defaults.
Why You Can't Fix It Afterwards
The thing that makes this worth building around is that the damage isn't recoverable.
07030 reads as 7030 and there's no later step that can put the zero back, because nothing downstream knows it was ever there. Five digits went in, four came out, and the four are a perfectly valid number. There's no error to catch and no exception to log.
Compare that to a parse error, which is loud, blocking and fixable. A parse error tells you where it stopped. This tells you nothing, and the file looks fine, and you find out when a lookup on an order id returns no rows and you spend an afternoon on it.
So a viewer has exactly one job here: don't be the step that does this.
A Parser That Never Calls Number
Every cell in this tool is a string, from the file to the grid to the export. There's no type inference, no date parsing, no trimming. 07030 is five characters, and it stays five characters through an edit, a sort, a filter and a download.
That sounds obvious and it's the part most tools get wrong, because reading a field as a number is convenient once and destructive forever. A sort wants numbers. A filter wants numbers. A chart wants numbers.
The sort is the one place I had to stop and think. If a column holds 2 and 10, sorting it as text puts 10 first, which is wrong. If a column holds 07030 and 10001, sorting it as a number is also wrong, because those are codes and their numeric value is not a thing anybody wants ordered.
So the column decides, using the same test. A column sorts as numbers only when every value in it survives Number and comes back the same. One leading zero anywhere in the column and the whole column is text, which is exactly what a column of zip codes is.
Empty cells took a second decision. Sorting a column upward puts them at the bottom, and sorting it downward also puts them at the bottom, because an empty cell isn't the smallest value or the largest one. It's a value that isn't there, and the place for those is out of the way, whichever direction you asked for.
Two Characters of Lookahead
The other half of the build was reading a file that does not fit in a string.
A 219 MB CSV can't be handed to a parser as one string and then held in memory next to the parsed rows. So the file is read with File.slice, a megabyte at a time, through a streaming TextDecoder, and pushed into a parser that keeps its state between chunks.
Almost all of that is easy. A state machine over characters doesn't care where you cut the input. There are exactly two places where it does:
A carriage return at the end of a chunk might be the first half of a CRLF, and you can't know until the next chunk arrives.
A closing quote at the end of a chunk might be the first half of an escaped quote, "", and you can't know until the next chunk arrives either.
Two characters of lookahead, two flags, and the parser survives any chunk boundary. I wrote the tests for both before the code, feeding a file in deliberately awkward pieces, because those are the two bugs that would show up on a big file and never on a small one.
What RFC 4180 Says, and What It Leaves Out
The specification is short, and short in a way that matters.
It says a field with a comma, a quote or a line break in it gets wrapped in double quotes, and that a quote inside a quoted field is written twice. It says records end with CRLF. It says every record should have the same number of fields.
It says nothing about a byte order mark. It says nothing about semicolons, which is what half of Europe exports, because a comma is a decimal point there. It says nothing about encodings past 7 bit ASCII, and nothing about what to do with a blank line in the middle.
So every real parser is the specification plus a pile of decisions, and the decisions are where files break. The useful thing a tool can do is say which decisions it made. My delimiter picker prints its reason under it: "every line splits into 8 fields". The encoding says whether it read a byte order mark, which is a fact, or guessed from the bytes, which is not.
The Blank Line Question
Here is a small decision that has no right answer.
A blank line in the middle of a CSV is, by the letter of the specification, a record with one empty field. By the intent of every file that has ever had one, it is nothing.
I skip them and count them, and the count goes on the screen: "3 blank lines were skipped". That way the decision is visible. A tool that quietly keeps them puts empty rows in your grid. A tool that quietly drops them is fine right up until you're counting rows, your count is off by three, and you have no idea why.
Byte for Byte, and the One Thing That Doesn't
The export writes back the delimiter the file used, the line endings it used, the quoting style it used and its trailing newline. A file you did not edit comes back byte for byte identical. There's a test that runs eleven awkward files through parse and serialize and compares the bytes, and a check on the live page that does the same thing to the example file.
One thing doesn't come back. A field that was quoted when it did not have to be loses its quotes, because quoting isn't part of a value. Some systems quote every field, which the exporter matches by noticing and quoting every field. Some quote a random subset, and those come back with minimal quoting and the same characters inside.
I could store a flag per cell and reproduce the original exactly. That's a bit per cell for a million rows, to keep something that carries no information. Saying so out loud on the page seemed better than carrying the memory.
What the Numbers Actually Are
I generated three files and read them in Chrome on this machine, against the built page.
| File | Rows | Read time | Heap after |
|---|---|---|---|
| 7.1 MB | 100,000 | 0.26 s | 37 MB |
| 72.3 MB | 1,000,000 | 1.5 s | 250 MB |
| 219.1 MB | 2,000,000, capped | 2.9 s | 476 MB |
The third one is the cap doing its job. The page stops at two million rows and says that it stopped.
The heap number is the one that decided the cap. Two million rows of eight columns is sixteen million strings, and JavaScript charges you an object header for every one of them. The file on disk is 219 MB and the same file in memory is 476 MB, which is the tax for keeping every value as the text it was instead of as a number.
I could raise it. At 476 MB of heap for two million rows, the next power of two is a tab that dies, and a tab that dies takes the file with it and gives you nothing, not even a partial view. A number on the screen that says where it stopped is more useful than a promise of unlimited that ends with a crash report.
What to Check in Your Own Export
If you send CSVs to anyone, or receive them, four things are worth thirty seconds.
- Open the file in a text editor before you open it in a spreadsheet. The text editor shows you what's in the file. The spreadsheet shows you what it decided the file meant.
- Look at any column that holds an identifier. Zip codes, order numbers, SKUs, phone numbers, anything with a leading zero or more than 15 digits. Those are the columns that get quietly rewritten.
- If you must open it in Excel, turn off the two conversions under File, Options, Data first, which you can only do on Microsoft 365 or Excel 2024, or import the file as text rather than double clicking it.
- Check the delimiter and the encoding of anything that came from another country. A semicolon file opened as a comma file is one enormous column, and at least that failure announces itself, which is more than the numeric one does.
And if you want to look at the thing without a spreadsheet in the way, the CSV Viewer and Editor is on the site now. It opens the file in the browser, counts the cells a spreadsheet would have changed and tells you before you scroll, and changes none of them. No upload, no account, and nothing you open leaves the tab.
Last updated: September 10, 2026 | Reading time: 10 minutes
Written by Evgeniy Poznyak, who spent longer than he expected reading a specification that fits on eight pages.