Skip to content
datahacks.dev
CSV

Converting Excel to CSV Without Losing Leading Zeros

pandas turned a text cell 02134 into 2134 and in2csv did too; qsv kept it. A number formatted 00000 loses its zeros everywhere unless you rebuild them.

10 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the Python logo.

"Convert this spreadsheet to CSV" arrives with a hidden assumption that the conversion is mechanical. It is not, because a cell holds two things, a stored value and a way of displaying it, and CSV can hold only one of them. A zip code typed as 02134 and stored as the number 2134 with a format that pads it to five digits shows 02134 on screen and exports as 2134. This article builds a small workbook with every variety of the problem, runs it through four readers, and ends with a script that exports what the sheet displays and tells you what it could not trust.

The workbook

Three people and nine columns. Two zip columns hold the same five digits in different ways: zip_text holds text cells, and zip_number holds the numbers 2134, 10001 and 0 with the number format 00000, which displays them as 02134, 10001 and 00000. The others are the usual suspects: an account number of 19 digits, a percentage, a date, a decimal, a formula and a boolean. The cells are what you would see in a spreadsheet:

t0150_make.pyPython
import datetime as dt
import xlsxwriter

OUT = "/batches/csv-0001-0200/work/conv/zips.xlsx"
wb = xlsxwriter.Workbook(OUT)
ws = wb.add_worksheet("data")
zip_fmt = wb.add_format({"num_format": "00000"})
txt = wb.add_format({"num_format": "@"})
pct = wb.add_format({"num_format": "0.0%"})
day = wb.add_format({"num_format": "yyyy-mm-dd"})
money = wb.add_format({"num_format": "#,##0.00"})
ws.write_row(0, 0, ["name", "zip_text", "zip_number", "account", "ratio", "joined", "amount", "total", "flag"])
data = [
    ("Ana", "02134", 2134, 1234567890123456789, 0.125, dt.date(2025, 3, 1), 1234.5, 3, True),
    ("Bo", "10001", 10001, 12345678901234567890, 1 / 3, dt.date(2025, 3, 2), 0.1 + 0.2, 4, False),
    ("Cy", "00000", 0, 99, 0.5, dt.date(2025, 12, 31), 100, 5, True),
]
for i, (n, z, zn, acct, ratio, d, amt, tot, flag) in enumerate(data, start=1):
    ws.write_string(i, 0, n)
    ws.write_string(i, 1, z, txt)
    ws.write_number(i, 2, zn, zip_fmt)
    ws.write_number(i, 3, acct)
    ws.write_number(i, 4, ratio, pct)
    ws.write_datetime(i, 5, dt.datetime.combine(d, dt.time()), day)
    ws.write_number(i, 6, amt, money)
    ws.write_formula(i, 7, f"=B{i+1}+0*1+{tot}", None)     # no cached value supplied
    ws.write_boolean(i, 8, flag)
wb.close()
print("made", OUT)

pandas

t0150.pyPython
import re, sys
import pandas as pd
from openpyxl import load_workbook

PATH = "/batches/csv-0001-0200/work/conv/zips.xlsx"

print("1. pandas.read_excel with defaults")
df = pd.read_excel(PATH)
print(df[["name", "zip_text", "zip_number", "account", "ratio", "joined", "amount"]].to_string())
print(df.dtypes.to_dict())

print("\n2. pandas.read_excel(dtype=str)")
print(pd.read_excel(PATH, dtype=str)[["zip_text", "zip_number", "account", "ratio", "joined"]].to_string())
Shell
python lab/conv/t0150.py
Text
1. pandas.read_excel with defaults
  name  zip_text  zip_number               account     ratio     joined  amount
0  Ana      2134        2134   1234567890123457024  0.125000 2025-03-01  1234.5
1   Bo     10001       10001  12345678901234569216  0.333333 2025-03-02     0.3
2   Cy         0           0                    99  0.500000 2025-12-31   100.0
{'name': <StringDtype(na_value=nan)>, 'zip_text': dtype('int64'), 'zip_number': dtype('int64'), 'account': dtype('uint64'), 'ratio': dtype('float64'), 'joined': dtype('<M8[us]'), 'amount': dtype('float64'), 'total': dtype('int64'), 'flag': dtype('bool')}

2. pandas.read_excel(dtype=str)
  zip_text zip_number               account               ratio               joined
0    02134       2134   1234567890123457024               0.125  2025-03-01 00:00:00
1    10001      10001  12345678901234569216  0.3333333333333333  2025-03-02 00:00:00
2    00000          0                    99                 0.5  2025-12-31 00:00:00

3. openpyxl: the value and the number format are separate
  B2  s  '02134'                            format '@'
  C2  n  2134                               format '00000'
  C4  n  0                                  format '00000'
  D2  n  1.234567890123457e+18              format 'General'
  D3  n  1.234567890123457e+19              format 'General'
  E3  n  0.3333333333333333                 format '0.0%'
  F2  d  datetime.datetime(2025, 3, 1, 0, 0) format 'yyyy-mm-dd'
  G3  n  0.3                                format '#,##0.00'

4. Rebuilding what the sheet displays for a number with a 00000 format
   ['02134', '10001', '00000'] <- zip_number as displayed
   ['02134', '10001', '00000'] <- zip_text as stored

5. A formula that was written without a cached value
  data_only=False: H2 = '=B2+0*1+3'
  data_only=True: H2 = 0

The first block of the output is read_excel with defaults, and it is not good. zip_text, whose cells are text and contain 02134, came back as int64 with the values 2134, 10001 and 0. pandas (read_excel) runs the same type inference on an Excel column that it runs on a CSV column, so digit-only text becomes numbers even though the file said otherwise. zip_number (numbers) became 2134, 10001 and 0 too. The account number 1234567890123456789 came back as 1234567890123457024, which I come back to.

The second block uses dtype=str. zip_text is now right (02134, 10001, 00000), because the cells were text. zip_number is 2134, 10001 and 0: pandas reads the stored value and ignores the number format, so the padding is lost. The date comes back as 2025-03-01 00:00:00 and the ratio as 0.3333333333333333, which is the value and not the 33.3% that the sheet displays.

openpyxl: the value and the format are separate

The lower-level library shows the cell as it is stored:

Text
B2  s  '02134'     format '@'
C2  n  2134        format '00000'
C4  n  0           format '00000'
E3  n  0.3333333333333333  format '0.0%'
F2  d  datetime.datetime(2025, 3, 1, 0, 0)  format 'yyyy-mm-dd'

(Those are lines from the third block of the output.) B2 is a string; C2 is the number 2134 and its format is 00000; E3 is a third and its format makes it display as a percentage. Because the format is separate, you can reconstruct the display. For a format made only of zeros, the number of digits shown is the number of zeros:

t0150.pyPython
def shown(cell):
    """What a zero-padding format displays; anything else falls back to the stored value."""
    v, fmt = cell.value, cell.number_format
    if isinstance(v, (int, float)) and re.fullmatch(r"0+", fmt):
        return str(int(v)).zfill(len(fmt))
    return v if v is not None else ""


print("\n4. Rebuilding what the sheet displays for a number with a 00000 format")
print("  ", [shown(ws[r]) for r in ("C2", "C3", "C4")], "<- zip_number as displayed")
print("  ", [shown(ws[r]) for r in ("B2", "B3", "B4")], "<- zip_text as stored")

The fourth block of the output: ['02134', '10001', '00000'] for the number column and the same for the text column. That is what the sheet shows. It is a rule for one format family (0, 00, 00000), and it is a rule about this column. Formats like 0.00, #,##0 or @ need other rules, and a general version means implementing the number-format mini-language, which I would not do: decide per column which ones are identifiers and handle those.

Command-line converters

The tools that turn a workbook into a CSV in one command make their own choices, and the first two differ:

Shell
in2csv work/conv/zips.xlsx
Text
name,zip_text,zip_number,account,ratio,joined,amount,total,flag
Ana,2134,2134,1.234567890123457E+18,0.125,2025-03-01,1234.5,False,True
Bo,10001,10001,1.234567890123457E+19,0.3333333333333333,2025-03-02,0.3,False,False
Cy,0,0,99,0.5,2025-12-31,100,False,True
Shell
qsv excel work/conv/zips.xlsx
Text
name,zip_text,zip_number,account,ratio,joined,amount,total,flag
Ana,02134,2134,1234567890123457024,0.125,2025-03-01,1234.5,0,true
Bo,10001,10001,1.234567890123457e+19,0.3333333333333333,2025-03-02,0.3,0,false
Cy,00000,0,99,0.5,2025-12-31,100,0,true
3 9-column rows exported from "data" sheet
Columnin2csvqsv excel
zip_text (text cells)213402134
zip_number (format 00000)21342134
account (19 digits, first row)1.234567890123457E+181234567890123457024
total (formula, no cached value)False0
flag (boolean)Truetrue

in2csv wrote the text cell without its zero, so it applies number inference to text. qsv excel kept the text as text. Neither reconstructs the 00000 format. For the formula, in2csv wrote False and qsv wrote 0, and for the 19-digit account number one used scientific notation. Neither tool said anything about it. The lesson is not which tool is right; it is that a single in2csv call is a conversion with defaults you have not looked at.

The numbers Excel already rounded

An account number of 19 digits cannot be stored in a spreadsheet cell as a number. Excel stores numbers as IEEE doubles, which hold about 15 significant digits exactly (Microsoft documents the limit). The workbook above holds 1.234567890123457e+18; pandas prints it as 1234567890123457024 (the double converted to an integer), and the last digits are noise. If the number came in from a typed cell, the rounding happened when it was typed, and no converter can recover the original digits. Identifiers longer than 15 digits belong in text cells, and a column of them that is already numeric means the source needs fixing. The script below warns on any number at or above 2^53 so the loss is visible.

Formulas and their cached values

A formula cell holds the formula and, if the file came from Excel, the last result Excel calculated. A library that writes a workbook without calculating it stores no result. Reading the workbook:

Text
data_only=False: H2 = '=B2+0*1+3'
data_only=True:  H2 = 0

With data_only=False, openpyxl returns the formula text. With data_only=True, it returns the cached value, which was 0 here because the writer supplied none. That is what in2csv and qsv also reported (False and 0). A file saved by Excel will normally have real cached values; a file generated by a script may not, and the wrong answer arrives as a plausible number. If a column is a formula, check a few values by hand.

A script that exports what the sheet shows

Putting the rules together, with a warning for each place where it had to trust something:

xlsx2csv.pyPython
"""Excel to CSV that keeps what the sheet displays for identifiers, and says what it could not trust."""
import csv, datetime as dt, re, sys
from decimal import Decimal
from openpyxl import load_workbook

path, sheet, out = sys.argv[1:4]
wb_f = load_workbook(path)[sheet]                    # formulas as written
wb_v = load_workbook(path, data_only=True)[sheet]    # the values Excel cached
warnings = []


def text(cell, cached):
    v, fmt = cached.value, cell.number_format
    if cell.data_type == "f":
        warnings.append(f"{cell.coordinate}: formula {cell.value!r}, using the cached value {v!r}")
    if v is None:
        return ""
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, dt.datetime):
        return v.date().isoformat() if v.time() == dt.time() else v.isoformat()
    if isinstance(v, dt.date):
        return v.isoformat()
    if isinstance(v, (int, float)):
        if re.fullmatch(r"0+", fmt):                 # 00000: a zero-padded identifier
            return str(int(v)).zfill(len(fmt))
        if isinstance(v, float) and v.is_integer() and abs(v) < 2**53:
            return str(int(v))
        if abs(v) >= 2**53:
            warnings.append(f"{cell.coordinate}: {v!r} has more digits than a double holds")
        return repr(v)
    return str(v)


with open(out, "w", newline="", encoding="utf-8") as f:
    w = csv.writer(f, lineterminator="\n")
    for row_f, row_v in zip(wb_f.iter_rows(), wb_v.iter_rows()):
        w.writerow([text(a, b) for a, b in zip(row_f, row_v)])
for m in warnings:
    print("warning:", m, file=sys.stderr)
Shell
python lab/conv/xlsx2csv.py work/conv/zips.xlsx data work/conv/zips_out.csv 2>&1
echo ---
cat work/conv/zips_out.csv
Text
warning: D2: 1.234567890123457e+18 has more digits than a double holds
warning: H2: formula '=B2+0*1+3', using the cached value 0
warning: D3: 1.234567890123457e+19 has more digits than a double holds
warning: H3: formula '=B3+0*1+4', using the cached value 0
warning: H4: formula '=B4+0*1+5', using the cached value 0
---
name,zip_text,zip_number,account,ratio,joined,amount,total,flag
Ana,02134,02134,1.234567890123457e+18,0.125,2025-03-01,1234.5,0,true
Bo,10001,10001,1.234567890123457e+19,0.3333333333333333,2025-03-02,0.3,0,false
Cy,00000,00000,99,0.5,2025-12-31,100,0,true

The output file has 02134, 10001, 00000 in both zip columns, dates as 2025-03-01 (midnight dates lose the time), booleans as true/false and the percentage as its stored value 0.125. It printed five warnings, one for each of the two account numbers and one for each of the three formula cells. The warnings go to standard error, so they do not end up in the data. I would send them to the person who owns the workbook.

How to tell it worked

Open the workbook in the spreadsheet and the CSV in a text editor, and compare a few rows by eye, with your attention on the identifier columns. Compare the number of data rows with the sheet's row count (the last used row, not the highest row number). Read the CSV back with dtype=str and check that no value has lost its width. Search the sheet for cells that contain formulas, merged cells, hidden rows and columns, and multiple sheets, because a converter handles each of them in its own way. And keep the workbook next to the CSV with its checksum.

What a workbook made by Excel may add

Everything above came from a file made by a script, so it lacks things a real workbook has: a cached result for every formula, styles from a template, hidden sheets, defined names, text that is wrapped across merged cells, rich text with several formats in one cell and dates in either of two calendar systems. Those are the reasons to repeat the test on a real file and to keep the warning list. For a one-off conversion with a handful of identifier columns, set those columns to text in the source before saving as CSV. For a recurring conversion, treat the workbook as an input with a contract, and validate the CSV it produces. The reverse direction is in converting CSV to an Excel workbook.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.