How-to
Getting Started With Python Excel AutomationDeep dive

Write CSV Files That Open Correctly in Excel

A CSV that looks fine in a text editor can open in Excel with garbled accents, everything crammed into column A, IDs stripped of leading zeros and long numbers in scientific notation. Write it with a UTF-8 byte order mark, the separator and decimal mark the reader's Excel expects, ISO dates, protected identifiers and safe quoting — or decide that the file should really be an .xlsx.

Someone double-clicks the CSV your script produced and Excel shows Müller instead of Müller, or puts each whole line into column A, or turns order number 00412 into 412 and a 16-digit card reference into 4.53201E+15. Nothing is wrong with the file in the strict sense — Python wrote valid CSV — but Excel opens CSV files by guessing, and its guesses depend on the user's regional settings. Because the recipient will not use the import wizard, the file has to be written so that the guesses come out right. This page covers the encoding, separator, number and date choices that make a CSV open cleanly by double-click, and the cases where the honest answer is to send an .xlsx instead. It belongs to Handling Excel File Formats and Conversions.

Prerequisites

Bash
pip install pandas

The examples use one small DataFrame with the usual troublemakers: accented names, an amount with decimals, an ID with leading zeros, a long reference number, a date, and a free-text note containing a comma, quotes and a line break.

Python
from pathlib import Path
import pandas as pd

customers = pd.DataFrame({
    "CustomerID": ["00412", "00977", "01530"],
    "Name": ["Müller GmbH", "Café Señor", "Øresund ApS"],
    "Balance": [1234.5, -88.25, 0.0],
    "Reference": ["4532015112830366", "4716123456789012", "6011000990139424"],
    "Since": pd.to_datetime(["2019-03-04", "2021-11-30", "2024-01-15"]),
    "Note": ['Pays "on time", usually', "Call before\ndelivery", ""],
})
print(customers.dtypes)

Step 1 — Write UTF-8 with a byte order mark

Python
customers.to_csv("plain.csv", index=False)
customers.to_csv("excel.csv", index=False, encoding="utf-8-sig")
print(Path("plain.csv").read_bytes()[:12])
print(Path("excel.csv").read_bytes()[:12])

The only difference is the first three bytes: utf-8-sig writes EF BB BF, the UTF-8 byte order mark (BOM). When Excel opens a CSV by double-click it looks for that mark; without it, Windows Excel decodes the file with the legacy code page of the user's locale, which turns every ü into ü. Python's own pd.read_csv and most other tools ignore or strip the mark, so writing it costs nothing. This is the single most important setting in this page.

Three bytes decide how Excel decodes the file The same CSV opened in Excel. Without a byte order mark, Excel decodes it with the local code page and Müller GmbH appears as Müller GmbH. With the UTF-8 byte order mark written by encoding utf-8-sig, the name appears correctly. A B 1 CustomerID Name 2 412 Müller GmbH 3 977 Café Señor A B 1 CustomerID Name 2 412 Müller GmbH 3 977 Café Señor no BOM with BOM encoding="utf-8-sig"

Step 2 — Use the separator the reader's Excel expects

Excel splits a CSV on the list separator from the operating system's regional settings. In the United States and the UK that is a comma. In most of continental Europe, where the comma is the decimal mark, it is a semicolon — and a comma-separated file opens with every row crammed into column A. If you know the audience, write for it:

Python
customers.to_csv("excel_de.csv", index=False, encoding="utf-8-sig", sep=";", decimal=",")
print(Path("excel_de.csv").read_text(encoding="utf-8-sig").splitlines()[1])

sep=";" changes the separator and decimal="," writes 1234,5 instead of 1234.5, so German, French or Dutch Excel reads the balance as a number. Mixing them up is worse than either: a semicolon file with decimal points opens in German Excel with 1234.5 as text, or worse, as a date.

When one file must serve both kinds of user, Excel honours a first line of the form sep=;, which overrides the regional setting:

Python
with open("excel_any.csv", "w", encoding="utf-8-sig", newline="") as fh:
    fh.write("sep=;\n")
    customers.to_csv(fh, index=False, sep=";", decimal=",")
print(Path("excel_any.csv").read_text(encoding="utf-8-sig").splitlines()[:2])

Use the hint with care: it is an Excel convention, not part of the CSV format. Other programs, including pd.read_csv, see sep=; as the header row unless told to skip it (skiprows=1), so only add it to files meant purely for people.

Choosing the separator and decimal mark For readers whose Excel uses comma as the list separator, write comma-separated values with a decimal point. For readers in locales that use comma as the decimal mark, write semicolon-separated values with decimal comma. For a mixed audience, either add the sep= hint line or send an xlsx file. Where will the file be opened? US / UK settings sep="," decimal point continental Europe sep=";" decimal="," both sep= hint line Excel-only; or send xlsx

Step 3 — Keep leading zeros and long numbers

Excel converts anything that looks like a number. 00412 becomes 412, and a 16-digit reference becomes a floating-point number shown as 4.53202E+15 — and because Excel keeps only 15 significant digits, the last digit is replaced by a zero the moment the file is saved again. CSV has no way to mark a field as text, so the only CSV-level fix is to make the value not look like a number. The common trick is to write it as a formula that returns text:

Python
protected = customers.assign(
    CustomerID='="' + customers["CustomerID"] + '"',
    Reference='="' + customers["Reference"] + '"',
)
protected.to_csv("excel_ids.csv", index=False, encoding="utf-8-sig")
print(Path("excel_ids.csv").read_text(encoding="utf-8-sig").splitlines()[1])

Each ID is written as "=""00412""" — CSV quoting around ="00412" — which Excel evaluates as a formula producing the text 00412. It works by double-click, but it pollutes the file for every other consumer, which now receives ="00412" as the value. Use it only for files that exist to be opened in Excel; for anything else, IDs with leading zeros are a strong reason to send an .xlsx, where the cell can simply be text — see Format ZIP Codes and IDs with Leading Zeros.

Step 4 — Write dates Excel will recognise

A datetime64 column is written as 2019-03-04 by default (or with 00:00:00 if any value has a time). ISO yyyy-mm-dd is the safest choice: Excel recognises it as a date in every locale. Writing local formats such as 04/03/2019 invites the classic day–month swap, where an American Excel reads 4 March as April 3rd:

Python
customers.to_csv("excel_dates.csv", index=False, encoding="utf-8-sig", date_format="%Y-%m-%d")
print(Path("excel_dates.csv").read_text(encoding="utf-8-sig").splitlines()[1])

date_format controls every datetime column in one place. If times matter, "%Y-%m-%d %H:%M" is also recognised. Time zones should be converted and removed before writing; Excel has no notion of them.

Step 5 — Quote text with commas, quotes and line breaks

pandas quotes fields that contain the separator, a quote character or a line break, and doubles embedded quotes, which is exactly what Excel expects:

Python
line = Path("excel.csv").read_text(encoding="utf-8-sig")
print(line)

The note Pays "on time", usually is written as "Pays ""on time"", usually", and the note with a line break is wrapped in quotes so the break stays inside one cell. Do not "help" by stripping quotes or setting quoting=csv.QUOTE_NONE — that is what produces rows split across lines and shifted columns. Text that starts with =, +, - or @ is a different danger: Excel executes it as a formula, which is how formula injection works; neutralise it as described in Prevent Formula Injection in Excel Exports.

Settings that decide how a CSV opens in Excel A checklist of CSV problems and the to_csv setting that fixes each. Garbled accents need the UTF-8 BOM. Everything in column A needs the right separator. Numbers read as text need the matching decimal mark. Lost leading zeros need text protection or an xlsx. Swapped days and months need ISO dates. Broken rows need default quoting. to_csv setting Garbled accents e c o d i g = " u t f 8 s i g " Everything in column A s e p = " ; " ( o r s e p = h i t ) Numbers stay text d e c i m a l = " , " Leading zeros vanish = " 0 0 4 1 2 " t r i c k , o r x l s x Day and month swapped d a t e _ f o r m a t = " % Y % m % d " Rows broken by notes d e f a u l t q u o t i g

Step 6 — Know when CSV is the wrong format

Everything above works around one fact: CSV carries no types, and Excel guesses. When the file must preserve text IDs, exact long numbers, real dates and look right to people in different countries, an .xlsx written with to_excel simply stores each cell's type and needs no guessing at all:

Python
customers.to_excel("customers.xlsx", index=False)
from openpyxl import load_workbook
ws = load_workbook("customers.xlsx").active
print(ws["A2"].value, ws["A2"].data_type, "|", ws["D2"].value, ws["D2"].data_type)

The customer ID stays 00412 as text and the reference keeps all 16 digits. Send CSV when the consumer is another program or when the user explicitly asked for it; send .xlsx when a person will open the file. The trade-offs are compared in Excel vs CSV vs Parquet for Python Data Pipelines.

Common pitfalls

  • Plain UTF-8 without the BOM. Valid, and garbled in Windows Excel; use utf-8-sig.
  • Comma files for European users. One column per row; use sep=";" with decimal=",".
  • Mixing separator and decimal mark. Decimal commas inside comma-separated files split numbers across columns.
  • Expecting leading zeros to survive. They never do by double-click unless protected; better, send .xlsx.
  • Windows line endings. pandas writes \n; Excel copes either way, but pass lineterminator="\r\n" if a legacy system demands CRLF.

Performance notes

to_csv is far faster than to_excel — a million rows write in seconds rather than minutes — which is one honest reason to choose CSV for very large extracts. The settings above cost nothing measurable. Remember Excel's own ceiling: 1,048,576 rows per sheet. A larger CSV opens truncated, with a warning that is easy to miss, so split big extracts or tell the recipient to load them through Power Query instead of double-clicking.

Conclusion

Write CSV for Excel with encoding="utf-8-sig", match sep and decimal to the reader's regional settings (or add the Excel-only sep= hint line), write ISO dates, let pandas quote awkward text, and protect or avoid identifiers that look like numbers. When the file must keep types for people in more than one locale, send an .xlsx instead — it removes the guessing entirely.

Frequently asked questions

Why does Excel show strange characters like ü in my CSV? The file is UTF-8 without a byte order mark, so Excel decodes it with the local code page. Write it with encoding="utf-8-sig" in pandas to_csv, which adds the mark Excel looks for.

Why does my CSV open in a single column in Excel? Excel splits on the list separator from the system's regional settings, which is a semicolon in many European locales. Write with sep=";" and decimal="," for those users, or add a first line sep=; to tell Excel which separator to use.

How do I keep leading zeros in a CSV opened in Excel? CSV cannot mark a field as text, so Excel converts 00412 to 412. Writing the value as ="00412" makes Excel show it as text, but other programs then see that formula; sending an xlsx file with the column stored as text is the cleaner fix.

What date format should I use in a CSV for Excel? Use ISO dates, yyyy-mm-dd, with date_format="%Y-%m-%d". Excel recognises them in every locale, while formats such as 04/03/2019 are read as different dates in different countries.

More on file formats & conversion

More on file formats & conversion →