Fix CSV Leading Zeros Lost in Excel
The export looks perfect in Python and in a text editor:
customer_id,postcode,phone,ean
00417,01234,07700900123,0012345678905
Double-clicked in Excel it becomes 417, 1234, 7.70009E+11 and 12345678905. The recipient sends the file back with the damaged values, a lookup against the master data matches nothing, and someone proposes storing the codes without zeros "since Excel drops them anyway".
Root Cause
A CSV has no types. Every field is text, and the program that opens it decides what each value means. Excel's default behaviour when opening a .csv by double-click is to run each column through its general-format parser: a value consisting only of digits becomes a number, and numbers have no leading zeros, so 00417 becomes 417. Long digit strings such as phone numbers and EAN codes become floating-point numbers and lose precision or switch to scientific notation beyond 15 significant digits. Nothing in the file can override this — the format has no place to say "this column is text". Writing the file differently does not help either: quoting does not make Excel treat a value as text on open, and neither does padding with spaces. The fix must either change the file format, or change what the value looks like so Excel's parser refuses to convert it.
Minimal Diagnostic
Find which columns are at risk before exporting: values that are all digits and would change if parsed as numbers.
# pip install "pandas>=2.2"
from pathlib import Path
import pandas as pd
SOURCE = Path("out/customers.csv")
def at_risk(frame: pd.DataFrame) -> pd.DataFrame:
rows = []
for col in frame.columns:
values = frame[col].dropna().astype(str).str.strip()
if values.empty:
continue
digits = values.str.fullmatch(r"\d+")
leading_zero = (digits & values.str.startswith("0")).sum()
long_numbers = (digits & (values.str.len() > 15)).sum()
rows.append({
"column": col,
"all_digit_values": int(digits.sum()),
"would_lose_zeros": int(leading_zero),
"would_lose_precision": int(long_numbers),
"example": values[digits & values.str.startswith("0")].iloc[0] if leading_zero else "",
})
return pd.DataFrame(rows).query("would_lose_zeros > 0 or would_lose_precision > 0")
if __name__ == "__main__":
frame = pd.read_csv(SOURCE, dtype="string")
print(at_risk(frame).to_string(index=False))
column all_digit_values would_lose_zeros would_lose_precision example
customer_id 4820 4820 0 00417
postcode 1204 1204 0 01234
phone 4790 4790 0 07700900123
ean 4820 0 4820
Four columns are damaged the moment the file is opened. Reading with dtype="string" is essential for this check — reading with defaults would already have destroyed the evidence.
Fix: Send an .xlsx with the Column Formatted as Text
When the recipient opens the file in Excel, give them a file that carries types. An .xlsx with the column formatted as text keeps 00417 as text, and every other tool still reads it correctly. Changed lines carry comments.
# pip install "pandas>=2.2" xlsxwriter
from pathlib import Path
import pandas as pd
TEXT_COLUMNS = ["customer_id", "postcode", "phone", "ean"]
def export_excel_text_safe(frame: pd.DataFrame, dest: Path, text_cols: list[str]) -> Path:
out = frame.copy()
for col in text_cols:
out[col] = out[col].astype("string").str.strip() # changed: keep as text in pandas too
dest.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(dest, engine="xlsxwriter") as writer:
out.to_excel(writer, sheet_name="Data", index=False)
book, ws = writer.book, writer.sheets["Data"]
text_fmt = book.add_format({"num_format": "@"}) # changed: Excel's text format
for col in text_cols:
idx = out.columns.get_loc(col)
width = max(len(col), int(out[col].str.len().max() or 0)) + 2
ws.set_column(idx, idx, width, text_fmt) # changed: whole column as text
ws.freeze_panes(1, 0)
return dest
if __name__ == "__main__":
customers = pd.read_csv("out/customers.csv", dtype="string")
print(export_excel_text_safe(customers, Path("out/customers.xlsx"), TEXT_COLUMNS))
The @ number format tells Excel the column contains text, so values keep their zeros when the file is opened, when a user edits a cell, and when the file is saved again. Reading the data back with pd.read_excel(..., dtype={"customer_id": "string"}) returns the original strings. This is the only option that survives a round trip through a recipient who opens, edits and returns the file — which is what usually happens.
Variant Fix 1: CSV Is Required — Make the Values Non-Numeric
Some recipients and systems require CSV. Then the value has to stop looking like a number. The formula-style trick keeps zeros visible in Excel:
# pip install "pandas>=2.2"
import csv
from pathlib import Path
import pandas as pd
def export_csv_excel_text(frame: pd.DataFrame, dest: Path, text_cols: list[str]) -> Path:
out = frame.copy()
for col in text_cols:
values = out[col].astype("string").str.strip()
out[col] = values.map(lambda v: f'="{v}"' if pd.notna(v) else v) # Excel reads it as a formula
dest.parent.mkdir(parents=True, exist_ok=True)
out.to_csv(dest, index=False, quoting=csv.QUOTE_ALL, encoding="utf-8-sig")
return dest
This is a trade, not a fix: the file now contains ="00417", which pandas, databases and other systems read literally. Use it only for files a human will open in Excel and nothing else will parse, and strip the wrapper when reading such files back:
# pip install "pandas>=2.2"
import re
import pandas as pd
EXCEL_TEXT = re.compile(r'^="(.*)"$')
def unwrap_excel_text(frame: pd.DataFrame) -> pd.DataFrame:
out = frame.copy()
for col in out.select_dtypes(include=["object", "string"]).columns:
out[col] = out[col].astype("string").str.replace(EXCEL_TEXT, r"\1", regex=True)
return out
Variant Fix 2: Keep the CSV Clean and Tell Excel How to Open It
The cleanest CSV option leaves the data untouched and moves the type decision to the import. Excel's "From Text/CSV" import (Data ribbon) lets the recipient set columns to text, and Power Query remembers it. A short instruction sheet shipped with the file — or a .txt extension, which forces the import wizard instead of a direct open — avoids the double-click path entirely:
# pip install "pandas>=2.2"
import csv
from pathlib import Path
import pandas as pd
def export_csv_for_import(frame: pd.DataFrame, dest: Path, text_cols: list[str]) -> tuple[Path, Path]:
dest.parent.mkdir(parents=True, exist_ok=True)
frame.to_csv(dest, index=False, quoting=csv.QUOTE_MINIMAL, encoding="utf-8-sig", lineterminator="\r\n")
readme = dest.with_name(dest.stem + "-open-in-excel.txt")
readme.write_text(
"Do not double-click this file.\n\n"
"In Excel: Data > From Text/CSV > select the file > Transform Data,\n"
f"set these columns to Text: {', '.join(text_cols)} > Close & Load.\n\n"
"Double-clicking converts codes such as 00417 to 417.\n",
encoding="utf-8")
return dest, readme
For internal pipelines, the better answer is that nothing should double-click the file at all: machine-to-machine transfers read the CSV with explicit dtypes, as in fix pandas DtypeWarning mixed types, and the zeros never come under threat.
Recovering Values Already Damaged
Files returned with damaged codes can often be repaired when the original width is known and the codes are zero-padded to a fixed length:
# pip install "pandas>=2.2"
import pandas as pd
WIDTHS = {"customer_id": 5, "postcode": 5, "phone": 11, "ean": 13}
def repad(frame: pd.DataFrame, widths: dict[str, int]) -> tuple[pd.DataFrame, pd.DataFrame]:
out = frame.copy()
problems = []
for col, width in widths.items():
if col not in out.columns:
continue
values = out[col].astype("string").str.strip()
numeric = values.str.fullmatch(r"\d+")
too_long = numeric & (values.str.len() > width)
scientific = values.str.contains(r"[eE]\+?\d+", na=False) # 7.70009E+11: digits already lost
out.loc[numeric & ~too_long, col] = values[numeric & ~too_long].str.zfill(width)
for mask, reason in ((too_long, "longer than expected width"), (scientific, "scientific notation")):
if mask.any():
problems.append(pd.DataFrame({"column": col, "value": values[mask], "reason": reason}))
return out, (pd.concat(problems, ignore_index=True) if problems else pd.DataFrame(columns=["column", "value", "reason"]))
Zero-padding recovers 417 to 00417 only when every code is exactly five digits. Values that reached scientific notation have lost digits permanently — the returned file cannot be repaired, and the exchange has to be repeated with a format that preserves them. That is the argument for the .xlsx export: repair is not always possible.
Verification
Assert that exported values round-trip unchanged by reading the exported file back the way the recipient's tools will.
# pip install "pandas>=2.2" openpyxl
from pathlib import Path
import pandas as pd
def verify_export(original: pd.DataFrame, path: Path, text_cols: list[str]) -> None:
if path.suffix.lower() in {".xlsx", ".xlsm"}:
back = pd.read_excel(path, dtype={c: "string" for c in text_cols})
else:
back = pd.read_csv(path, dtype={c: "string" for c in text_cols}, encoding="utf-8-sig")
for col in text_cols:
expected = original[col].astype("string").str.strip().fillna("")
actual = back[col].astype("string").str.strip().fillna("")
changed = expected.compare(actual)
assert changed.empty, f"{col}: {len(changed)} value(s) changed, e.g. {changed.head(1).to_dict()}"
zeros = expected.str.startswith("0").sum()
assert (actual.str.startswith("0").sum()) == zeros, f"{col}: leading zeros lost"
print(f"{path.name}: {len(text_cols)} text column(s) round-trip unchanged")
For the .xlsx path, also open the file once in Excel and check a padded code visually before promising a supplier that their codes will survive — the automated check proves the file is right, not that the recipient's Excel settings are.
FAQ
Does quoting the value in the CSV help? No. Excel ignores quotes when deciding a column's type on open; quotes only protect delimiters and line breaks inside fields.
What about prefixing values with an apostrophe?'00417 displays as 00417 in Excel and keeps the zeros, but the apostrophe is part of the value for every other reader — the same trade as the ="..." trick.
Why do some recipients see the problem and others not? Regional settings and previously used import settings differ. Power Query remembers per-file settings, so a colleague who once imported the file correctly keeps getting correct results.
Can I stop Excel converting long numbers to scientific notation? Only by making the column text — the same fix. Excel switches to scientific notation above 11 digits in a default-width column and loses precision beyond 15 significant digits, which is why EAN and IBAN columns must never travel as plain CSV numbers.
Is there a CSV convention for types? Not in the format itself. Schema sidecars such as CSV on the Web or Frictionless Data describe types, but Excel does not read them.
Related
- Exporting Data to CSV Formats — delimiters, encodings and export options
- Export CSV with a Custom Delimiter and Quoting — matching a recipient's import rules
- Fix Merge Indicator Unexpected left_only Rows — what damaged codes do to joins
- Write Multiple Sheets to One Excel File — building the workbook this export becomes
Part of Exporting Data to CSV Formats.