The guide
A spreadsheet file stores numbers, not dates: getting a faithful CSV out of an .xlsx
An .xlsx workbook is a ZIP of XML parts, and every date in it is a bare number that only becomes a date through its style and the workbook's date system.
- 2 date systems 1900 and 1904; one flag in the workbook decides which
- Serial 60 29 February 1900, a day that never happened
- EF BB BF the UTF-8 byte order mark, a signature some programs use
- RFC 4180 the memo that wrote down how CSV quoting works
Read the full guide Fold the guide
Open a workbook in a spreadsheet program and you see dates, money, percentages and tidy blank cells. Save it as CSV and you sometimes get five-digit numbers where the dates were, a column that slid one place to the left, or accented names turned into scrambled pairs of symbols. None of that is random. Each mistake comes from a specific thing the workbook stores in a way the screen hides.
The short version: an .xlsx file keeps values and appearance apart. The value is what the cell holds. The appearance is a style, applied later. A converter that copies values without reading styles writes numbers where you saw dates. One that reads the screen instead of the file loses precision. Getting a faithful CSV means reading both, the way the standard describes them.
What is inside an .xlsx
An .xlsx file is a ZIP archive. The format, Office Open XML, was standardised by Ecma International as ECMA-376 and later by ISO and IEC, and its own description is a zipped, XML-based format for spreadsheets, documents and presentations. Any ZIP tool can list its parts.
Those parts follow the Open Packaging Conventions in Part 2 of the standard. A small file of relationships at the root points to the workbook part. The workbook lists its sheets by name and order, marks some of them hidden, and points through its own relationships to one XML part per worksheet. Beside them sit a table of shared strings and a styles part.
The ZIP layout matters because a workbook can be large. A ZIP file ends with a central directory that lists every entry and where it starts, so a reader can find one part without reading the rest of the archive. The browser can do the same thing with a file on your disk: the Blob slice method hands over a byte range without loading the whole file. Each part is usually compressed with DEFLATE, and current browsers can undo that with a DecompressionStream set to the raw deflate format. The original ZIP format stopped at 4 GiB and 65,535 entries; the ZIP64 extension carries larger sizes in extra fields, and a careful reader checks for them.
Compression also invites abuse. A zip bomb is a small archive that expands into something enormous, and deflate alone can reach ratios around a thousand to one. So a reader that runs on your own device should count every byte that comes out of the decompressor, compare the count with the size the directory claims, and stop the moment either looks wrong. Trusting the header is not enough, because the header is written by whoever made the file.
Text cells usually do not hold their text. A cell of type s holds a number, an index into the shared string table, so a word used ten thousand times is stored once. A shared string can be plain or made of rich-text runs with their own fonts; the text is the runs joined in order. East Asian workbooks may also carry phonetic hints, which belong to the reading of the text and not to the text itself.
A few cells keep their text inline instead, and formula cells that produce text store it beside the formula. Booleans are stored as 1 or 0, and errors as their code, such as #DIV/0!. Characters that XML cannot carry are escaped in a form like x000D for a carriage return, which a converter has to undo or the CSV fills up with odd codes.
Every cell can name a style. The style points to a number format, either one of the built-in formats the standard lists by id or a custom code stored in the workbook. Id 14 is a short date, ids 18 to 21 are times, and id 22 is a date with a time. A custom code such as yyyy-mm-dd or d mmm yyyy hh:mm says the same thing in letters. The number underneath is identical either way.
Why dates are numbers, with two starting points
A date in a cell is a serial number: whole days counted from a base date, with the time of day as the fraction. Midday is .5. The standard defines two systems, and a single attribute on the workbook, date1904, picks one. In the 1904 system, serial 0 is 1 January 1904. In the 1900 system used by most workbooks, serial 1 is 1 January 1900.
The 1900 system carries a famous flaw on purpose. For compatibility with an older spreadsheet program, it accepts 29 February 1900, although 1900 was not a leap year. The standard’s transitional clause states the limits rather than the flaw: serial 1 is 1 January 1900 and serial 2,958,465 is 31 December 9999. Count the real days between those dates and you get 2,958,463, one fewer than the serials allow. The missing day is serial 60.
So a faithful reader treats serials 1 to 59 as January and February 1900, writes serial 60 as 1900-02-29 and flags it, and counts everything from 61 onward from 30 December 1899. Serial 61 is 1 March 1900.
Get the offset wrong and every early date in a sheet shifts by a day, while modern dates still look right.
Here is a worked example you can reproduce with the example workbook. Its Date quirks sheet holds 46030.75 in a cell styled d mmm yyyy hh:mm. The whole part, 46030, counted in the 1900 system, is 8 January 2026, and three quarters of a day is 18:00. The CSV gets 2026-01-08T18:00:00. The same sheet holds serial 60 with a date style, written 1900-02-29 and listed in the report. In a workbook set to the 1904 system, that same 60 would be 1 March 1904.
ISO 8601 is the right target for that text. It writes the year first with fixed widths, joins date and time with the letter T, and sorts correctly as plain text. Any program that reads the CSV can parse it without guessing whether 03/04 means March or April.
Gaps, merges and the last saved value
A worksheet stores only the cells that hold something. Each cell carries its own reference, such as F2, and a row may jump from column C to column F with nothing between. If a converter ignores those references and writes values side by side, every value after a gap lands in the wrong column. That is the most common silent error in quick conversions, and it is the test the converter here is built to fail loudly on.
Merged cells are another trap. A merged range stores its value in the top-left cell only; the others are empty. Some readers of the CSV want that, because it is what the file holds. Others want the value repeated across the range so each row stands alone. Both are honest, so the choice belongs to whoever receives the file.
Formulas are stored twice: the formula text and the result the program saved last time it calculated. A converter that does not run a calculation engine can only pass that saved result along. Usually that is fine.
When a file was written by a program that never calculated, a formula has no saved result at all, and the honest move is to leave the cell empty and say so, rather than invent a value.
Choosing a delimiter and an encoding
CSV went undocumented for decades before RFC 4180 wrote down the common practice in 2005. Records end with CRLF. A field holding a comma, a double quote or a line break is enclosed in double quotes, and a quote inside such a field is doubled. The memo leaves room for variation, and in practice the delimiter is often a semicolon, a tab or a pipe. The same quoting rule works for each: quote any field that contains the delimiter.
Encoding causes the scrambled names. TextEncoder in the browser always writes UTF-8. The byte order mark EF BB BF is neither required nor recommended for UTF-8, yet some programs use it as a signature to tell UTF-8 apart from older encodings, and misread the file without it. UTF-16 little-endian starts with FF FE, as RFC 2781 describes, and suits programs that expect it. Pick what the receiving program reads, not what looks tidiest.
Numbers deserve the same care. A double such as 0.1 plus 0.2 is stored as 0.30000000000000004, and that exact text reads back to the same value. Written as displayed through a format of 0.00 it becomes 0.30, which is what a person saw but not what the file held. Keeping the stored form is the safe default for data; the displayed form suits reports.
One more risk sits on the receiving side. When a text cell begins with =, +, - or @, a spreadsheet that opens the CSV may treat it as a formula. OWASP describes this as CSV injection and suggests prefixing such cells with a single quote, while warning that the escaping is not reliable once a file is saved and reopened.
What this method cannot do
Reading a workbook this way gives you the stored values exactly. It does not recalculate formulas, so a stale saved result stays stale. It does not read the older binary .xls format, which is a different file structure altogether. The displayed form covers simple number formats only; fractions and conditional formats fall back to the stored value, and the report says which cells did. Within those limits, the CSV holds what the workbook holds, in the place it held it.
Sources
Every source consulted for this guide. Links checked October 25, 2026.
- ECMA-376 Ecma International
- Office Open XML - Wikipedia Wikipedia
- ZIP (file format) - Wikipedia Wikipedia
- APPNOTE.TXT - .ZIP File Format Specification PKWARE
- RFC 1951: DEFLATE Compressed Data Format Specification version 1.3 RFC Editor
- Zip bomb - Wikipedia Wikipedia
- Year 1900 problem - Wikipedia Wikipedia
- ISO 8601 - Wikipedia Wikipedia
- RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files RFC Editor
- Comma-separated values - Wikipedia Wikipedia
- Byte order mark - Wikipedia Wikipedia
- RFC 2781: UTF-16, an encoding of ISO 10646 RFC Editor
- CSV Injection OWASP Foundation
- DecompressionStream: DecompressionStream() constructor MDN Web Docs
- TextDecoder - Web APIs MDN Web Docs
- TextEncoder - Web APIs MDN Web Docs
- Blob: slice() method MDN Web Docs
- Using Web Workers MDN Web Docs