A CSV is text with commas in it. It has no column types: nothing in the file
says that 00412 is a customer number and not the number 412, or that
2026-03-04 is a date. Every program that turns a CSV into a spreadsheet
guesses, and for IDs, postcodes, phone numbers and long reference numbers the
guess is wrong. Converting without breaking the data means making those
decisions yourself, column by column.
A CSV converted to an Excel workbook in the browser.
Read every column as text, then convert only the columns that really are numbers or dates:
import pandas as pd
df = pd.read_csv("customers.csv", dtype=str, keep_default_na=False)
df["joined"] = pd.to_datetime(df["joined"], format="%Y-%m-%d").dt.date
df["balance"] = pd.to_numeric(df["balance"])
df.to_excel("customers.xlsx", index=False)
It needs pandas and openpyxl (on Debian and Ubuntu,
sudo apt install python3-pandas python3-openpyxl). Opened again with
openpyxl, the result has 00412 and the 19-digit order numbers as text cells,
joined as a date formatted YYYY-MM-DD, and balance as a number.
What the guessing does
The test file is three customers, written the way a CRM export writes them (UTF-8, comma-separated):
customer_id,name,postcode,phone,order_ref,joined,balance
00412,Zoë Müller,02134,0612345678,7310000012345678901,2026-03-04,1234.50
00977,Łukasz Nowak,08001,0031201234567,7310000012345678902,2026-11-12,87.05
01500,Søren Ågård,2100,+4512345678,7310000012345678903,2026-01-31,-15.00
Each converter's .xlsx was opened again to read the type and value of every
cell. The first data row:
| Column | In the CSV | LibreOffice, no options | pandas, typed | CSV to XLSX (browser) |
|---|---|---|---|---|
customer_id |
00412 |
412 | 00412, text |
00412, text |
postcode |
02134 |
2134 | 02134, text |
02134, text |
phone |
0612345678 |
612345678 | 0612345678, text |
0612345678, text |
order_ref |
7310000012345678901 |
7.31000001234568e+18 | 7310000012345678901, text |
7310000012345678901, text |
joined |
2026-03-04 |
2026-03-04, a date | 2026-03-04, a date | 2026-03-04, a date |
balance |
1234.50 |
1234.5 | 1234.5 | 1234.5 |
The order numbers are the worst case. Read as numbers, all three rows become
the same value, 7310000012345679000, because a spreadsheet number holds about
15 significant digits and these have 19. LibreOffice's 7.31000001234568e+18
is that value. Microsoft's own page on
keeping leading zeros and large
numbers
gives the same limit for Excel: 15 digits, and anything longer must be text.
The browser converter's date is the date serial 46085 with the number format
yyyy-mm-dd. It is worked out from the digits alone, so the time zone doesn't
come into it: we got exactly 46085 with the computer set to New York.
There is one difference between pandas and the browser converter that the first
row doesn't show. pandas was told that postcode is a text column. The browser
converter decides cell by cell, so Søren's postcode 2100, which has no leading
zero, became the number 2100, in a column where the other two are text. Sorting
and lookups treat the two kinds differently. When a column has to be text all
the way down, say so in pandas or LibreOffice.
Convert with pandas
The script above is the whole job for one file. Reading with dtype=str keeps
every value exactly as it was written; keep_default_na=False stops pandas
turning empty cells and words like NA into missing values. Then convert the
columns that are numbers or dates on purpose.
Dates need .dt.date. Without it pandas writes a date and time (format
YYYY-MM-DD HH:MM:SS), and with openpyxl as the writer the date_format
option of ExcelWriter was ignored in our test (pandas 2.2.3).
If you would rather name the text columns and let pandas read the rest, pass a dictionary. This version also handles a European export, with semicolons between fields and a comma as the decimal mark:
import pandas as pd
df = pd.read_csv("customers-semicolon.csv", sep=";", decimal=",",
keep_default_na=False,
dtype={"customer_id": str, "postcode": str,
"phone": str, "order_ref": str})
df["joined"] = pd.to_datetime(df["joined"], format="%Y-%m-%d").dt.date
df.to_excel("customers-eu.xlsx", index=False)
1234,50 comes out as the number 1234.5. Three more things the file may need:
- Day-first dates (
04/03/2026for 4 March):format="%d/%m/%Y". An explicit format is the only safe way. Guessed, that date is 3 April. - A Windows export in the old Western code page:
encoding="cp1252". Without it, pandas stops withUnicodeDecodeErrorrather than writing broken accents, which is the better failure. - A byte order mark at the start of the file: nothing to do. pandas 2.2.3
skipped it and read the first header as
customer_id.
A folder of exports
from pathlib import Path
import pandas as pd
TEXT = {"customer_id": str, "postcode": str, "phone": str, "order_ref": str}
for csv in Path("exports").glob("*.csv"):
df = pd.read_csv(csv, dtype=TEXT, keep_default_na=False)
df["joined"] = pd.to_datetime(df["joined"], format="%Y-%m-%d").dt.date
df.to_excel(csv.with_suffix(".xlsx"), index=False)
print(csv.with_suffix(".xlsx"), len(df), "rows")
exports/february.xlsx 3 rows
exports/january.xlsx 3 rows
Convert with LibreOffice
LibreOffice converts from the command line without opening a window, and its CSV filter takes the column types as a string of numbers:
soffice --headless \
--infilter="Text - txt - csv (StarCalc):44,34,76,1,1/2/2/2/3/2/4/2/5/2/6/5/7/1,1033" \
--convert-to xlsx customers.csv
convert /w/customers.csv as a Calc document -> /w/customers.xlsx using filter : Calc Office Open XML
The string, token by token:
| Token | Here | Means |
|---|---|---|
| 1 | 44 |
Field separator, as a character code: 44 comma, 59 semicolon, 9 tab |
| 2 | 34 |
Text is quoted with " |
| 3 | 76 |
Character set UTF-8. Use 1 for a Windows-1252 file |
| 4 | 1 |
Start at line 1 |
| 5 | 1/2/2/2/… |
Column/format pairs: 1 standard, 2 text, 4 DD/MM/YY, 5 YY/MM/DD |
| 6 | 1033 |
Language used to read numbers: 1033 English (US), 1031 German for decimal commas |
So columns 1 to 5 are text, column 6 is a year-month-day date and column 7 is
read as a number. In the result, the IDs, postcode, phone and order numbers are
text cells with Excel's text format (@), so they stay text if someone edits
them later. LibreOffice's CSV filter
options
page lists the rest.
For the semicolon file with decimal commas, change two tokens:
59,34,76,1,1/2/2/2/3/2/4/2/5/2/6/5/7/1,1031. For day-first dates, give that
column format 4 instead of 5. For a folder, pass a wildcard and an output
directory:
soffice --headless \
--infilter="Text - txt - csv (StarCalc):44,34,76,1,1/2/2/2/3/2/4/2/5/2/6/5/7/1,1033" \
--convert-to xlsx --outdir out exports/*.csv
Without --infilter, LibreOffice guesses: it dropped the
leading zeros, turned the order numbers into 7.31000001234568e+18, and put the
semicolon file into one column, split again at each decimal comma. It did read the ISO dates as dates.
In the browser
The CSV to XLSX Converter types each cell by
rules that never change a value: text that only looks like a number stays
text, plain numbers become numbers, and YYYY-MM-DD becomes a date. It is the
quick route for one file when you can't install anything.
- Drop the CSV on the page. It reads the encoding (a byte order mark, then
UTF-8, then Windows-1252), the delimiter (comma, semicolon, tab or pipe:
whichever gives the rows the same number of columns, not counting
delimiters inside quotes, or the one a
sep=first line names) and the decimal mark. - Click the eye icon (Show preview) and look at the data before you convert. What you see is what the file will hold.
- If something was read wrong, open the file's settings (Show settings, or Open settings on a phone) and set Input delimiter, Decimal separator or Dates like 04/03/2026.
- Click Convert 1 File, then Download.
The preview shows what the workbook holds before you download it.
The workbook made from our test file holds what the preview shows: 00412, 02134, 0612345678 and the order numbers as text cells,
Zoë Müller with its accents, the date as 46085 formatted yyyy-mm-dd, and
1234.5 as a number.
Two settings cover what a file can't say for itself:
- Decimal separator. A semicolon file is read with decimal commas when its
numbers look like
1234,50and none look like1234.50, so1234,50becomes 1234.5. A comma-separated file always uses a point. Set it by hand for a file that mixes the two. - Dates like 04/03/2026. Nothing in the file says whether that is 4 March
or 3 April, so by default such dates stay text, exactly as written. Choose
EU (DD/MM/YYYY) or US (MM/DD/YYYY) and they become real dates, shown
as
yyyy-mm-dd.
Which to use
- One file, no install: the CSV to XLSX Converter, after a look at the preview. It keeps IDs, phone numbers and long references as text and reads ISO dates as dates without being told.
- A column that must be one type all the way down (postcodes where some
have no leading zero, a code that is sometimes all digits): pandas with
dtype=str, or LibreOffice with a filter string. They type the column, not the cell. - Many files with the same columns: the pandas loop or
sofficewith a wildcard. Check one result before you send the batch. - A one-off file in Excel itself: Data › From Text/CSV, with types set per column. See why your CSV opened wrong.
Before you send the .xlsx on, open it and click a cell in each ID column: the
leading zeros should be there, and the full number should show in the formula
bar.