How-to
Formatting And Charting Excel Reports With PythonDeep dive

Localise an Excel Report for Other Languages and Locales

Produce one Excel report for readers in Germany, France and Poland without breaking it: let number formats follow each reader's separators, use the locale-aware short date, pin month names to a language with LCID tags, place currency symbols, translate labels from one table, set right-to-left sheets, and check the result under each locale.

A report that works for readers in London often goes wrong in Berlin or Paris, and the usual mistake is made with good intentions: formatting numbers and dates as strings in Python, in the reader's style, before writing them. The cell then shows 1.234.567,89 as text, cannot be summed, and looks wrong to anyone with different settings. Excel already localises a great deal by itself if the file is written the right way. This page shows what Excel adapts automatically, what you must pin explicitly, and how to check the result under several locales without owning several computers. It is part of Applying Number and Date Formats in Excel.

Prerequisites

Bash
pip install openpyxl pandas

The checks render the workbook with LibreOffice under different locale settings, using the LC_ALL environment variable, and read the result with pdftotext. Excel applies the same rules from the reader's Windows or macOS regional settings.

Step 1 — What adapts by itself

A number format code in an .xlsx file is locale-neutral: in #,##0.00, the comma means "thousands separator" and the dot means "decimal separator". The reader's application substitutes its own characters. The same goes for the built-in short date, which openpyxl calls mm-dd-yy (format number 14): it is not a fixed pattern but "the reader's short date". Write a small sheet and render it under three locales:

Python
import datetime as dt
from openpyxl import Workbook

wb = Workbook()
ws = wb.active
ws.append(["Label", "Value", "Format"])
rows = [
    ("Revenue", 1234567.891, "#,##0.00"),
    ("Short date", dt.date(2026, 10, 5), "mm-dd-yy"),
    ("Month", dt.date(2026, 10, 5), "mmmm yyyy"),
    ("Fixed pattern", dt.date(2026, 10, 5), "dd/mm/yyyy"),
]
for label, value, fmt in rows:
    ws.append([label, value, fmt])
    ws.cell(row=ws.max_row, column=2).number_format = fmt
ws.column_dimensions["A"].width = 16
ws.column_dimensions["B"].width = 22
wb.save("neutral.xlsx")
Bash
for loc in en_US de_DE fr_FR; do
  mkdir -p "$loc"
  LC_ALL=$loc.UTF-8 soffice -env:UserInstallation=file://$PWD/lo_$loc --headless --convert-to pdf --outdir "$loc" neutral.xlsx > /dev/null 2>&1
  echo "== $loc"; pdftotext -layout "$loc/neutral.pdf" - | sed -n '2,5p'
done

From one file: 1,234,567.89, 1.234.567,89 and 1 234 567,89; the short date as 10/5/2026, 05.10.2026 and 05/10/2026; the month as October, Oktober, octobre. Even dd/mm/yyyy changed in German, because / in a date format stands for "the date separator". Nothing in the file had to change.

One file, three locales A table of the same four cells rendered under English US, German and French settings. The revenue uses each locale's separators, the short date follows each locale's order and separator, the month name is translated, and even the fixed dd/mm/yyyy pattern uses the German dot separator. Cell en-US de-DE fr-FR Revenue 1,234,567.89 1.234.567,89 1 234 567,89 Short date 10/5/2026 05.10.2026 05/10/2026 Month October 2026 Oktober 2026 octobre 2026 dd/mm/yyyy 05/10/2026 05.10.2026 05/10/2026

Step 2 — Never pre-format numbers and dates as text

The consequence of Step 1 is the most important rule: write numbers and dates as values with format codes, never as formatted strings. A string formatted for Germany is wrong for everyone else and useless for calculation:

Python
from openpyxl import load_workbook

wb = Workbook()
ws = wb.active
ws["A1"] = f"{1234567.891:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")   # the tempting mistake
ws["A2"] = 1234567.891
ws["A2"].number_format = "#,##0.00"
ws["A3"] = "=SUM(A1:A2)"
wb.save("strings_vs_values.xlsx")
print({c: (repr(ws[c].value), ws[c].data_type) for c in ("A1", "A2")})

A1 is text and SUM ignores it; A2 is a number that every reader sees in their own format. Fix "Number Stored as Text" Warnings in Excel Output covers cleaning up files where this has already happened.

Step 3 — Pin the language when it must not follow the reader

Sometimes the report's language is fixed: a German-language report should say "Oktober" even when opened on an English computer. Prefix the format with a locale tag, [$-407] for German, [$-40C] for French, [$-415] for Polish; the number is the Windows locale ID in hexadecimal:

Python
LOCALES = {"de": "407", "fr": "40C", "pl": "415"}

wb = Workbook()
ws = wb.active
for row, (lang, lcid) in enumerate(LOCALES.items(), start=1):
    ws.cell(row=row, column=1, value=lang)
    cell = ws.cell(row=row, column=2, value=dt.date(2026, 10, 5))
    cell.number_format = f"[$-{lcid}]dddd d mmmm yyyy"
ws.cell(row=4, column=1, value="de-DE tag")
ws.cell(row=4, column=2, value=dt.date(2026, 10, 5)).number_format = "[$-de-DE]dddd d mmmm yyyy"
ws.column_dimensions["B"].width = 34
wb.save("pinned.xlsx")
Bash
LC_ALL=en_US.UTF-8 soffice -env:UserInstallation=file://$PWD/lo_en_US --headless --convert-to pdf pinned.xlsx > /dev/null 2>&1
pdftotext -layout pinned.pdf - | sed -n '1,4p'

Rendered on an English system, the month and day names are German, French and Polish, and the tag handles grammar too: Polish uses the genitive października after a day number, not the nominative październik of a month on its own. That is one more reason to let the format do the work instead of building date strings from a list of month names in Python. Use the hexadecimal form: the fourth row uses a tag written as a language name, [$-de-DE], and LibreOffice did not understand it, showing the raw date serial number instead of a date.

Step 4 — Currency symbols

Currency formats put the symbol in a bracketed block with its own locale ID, which also decides whether it goes before or after the number in the format code you write:

Python
wb = Workbook()
ws = wb.active
formats = {"EUR, German style": '#,##0.00 [$€-407]', "EUR, French style": '#,##0.00 [$€-40C]',
           "PLN": '#,##0.00 [$zł-415]', "GBP": '[$£-809]#,##0.00'}
for row, (label, fmt) in enumerate(formats.items(), start=1):
    ws.cell(row=row, column=1, value=label)
    ws.cell(row=row, column=2, value=1234567.891).number_format = fmt
ws.column_dimensions["A"].width = 20
ws.column_dimensions["B"].width = 20
wb.save("currency.xlsx")
Bash
LC_ALL=de_DE.UTF-8 soffice -env:UserInstallation=file://$PWD/lo_de_DE --headless --convert-to pdf currency.xlsx > /dev/null 2>&1
pdftotext -layout currency.pdf - | sed -n '1,4p'

The separators still follow the reader (German here); the symbol and its position are fixed by the format. Format Excel Cells as Currency with Python goes through negative amounts and accounting alignment.

Step 5 — Translate the labels from one table

Headers, sheet names and notes are text, and nothing translates them for you. Keep every label in one table keyed by an internal name, and build each language's report from the same data:

Python
import pandas as pd

LABELS = {
    "sheet":   {"en": "Sales", "de": "Umsatz", "fr": "Ventes"},
    "region":  {"en": "Region", "de": "Region", "fr": "Région"},
    "revenue": {"en": "Revenue", "de": "Umsatz", "fr": "Chiffre d'affaires"},
    "month":   {"en": "Month", "de": "Monat", "fr": "Mois"},
}
data = pd.DataFrame({"region": ["North", "South"], "month": pd.to_datetime(["2026-10-01", "2026-10-01"]),
                     "revenue": [48250.5, 39120.0]})

for lang in ("en", "de", "fr"):
    frame = data.rename(columns={k: LABELS[k][lang] for k in data.columns})
    with pd.ExcelWriter(f"sales_{lang}.xlsx", engine="xlsxwriter", datetime_format="mmmm yyyy") as writer:
        frame.to_excel(writer, index=False, sheet_name=LABELS["sheet"][lang])
        ws = writer.sheets[LABELS["sheet"][lang]]
        ws.set_column(1, 1, 16)
        ws.set_column(2, 2, 18, writer.book.add_format({"num_format": "#,##0.00"}))
    with pd.ExcelFile(f"sales_{lang}.xlsx") as book:
        print(lang, book.sheet_names, list(frame.columns))
One dataset, one workbook per language A single DataFrame and a label table feed three builds, one each for English, German and French, each with translated headers and sheet names but the same values and format codes. Data + label table one source of truth sales_en.xlsx Sales · Revenue sales_de.xlsx Umsatz · Monat sales_fr.xlsx Ventes · Mois

Missing translations are the common failure: a new column is added to the data and the label table is not updated. A KeyError in the rename is the good outcome; to catch it before a release, check that every key has every language:

Python
missing = [(key, lang) for key, names in LABELS.items() for lang in ("en", "de", "fr") if not names.get(lang)]
print("missing translations:", missing or "none")

Data values such as region names are translated the same way if the audience needs it; keep the untranslated key in a hidden column if the report is ever read back by a program.

Things that do not change with the locale

  • Formulas. Files always store English function names with commas as argument separators; Excel shows them translated. Write =SUMME(A1;A2) and the file is broken. See Fix #NAME? in Formulas Written from Python (XLOOKUP, TEXTJOIN, IFS).
  • Right-to-left languages. For Arabic or Hebrew reports, set ws.sheet_view.rightToLeft = True so column A is on the right.
  • Literal separators. To force a slash whatever the locale, escape it: dd\/mm\/yyyy.
Follows the reader, or fixed by you Two panels. Separators in number formats, the short date format and untagged month names follow the reader's settings. Month and day names with an LCID tag, currency symbols, translated labels, sheet direction and English formulas in the file are fixed when the report is written. Follows the reader thousands and decimal separators short date (format 14) untagged month names date separator / Fixed in the file [$-407] month and day names currency symbol and position labels and sheet names right-to-left, formulas

Common pitfalls

  • Formatting in Python. Strings like 1.234,56 cannot be summed and look wrong elsewhere; write values with format codes.
  • Fixed date patterns for international readers. dd/mm/yyyy confuses American readers; the short date follows each reader.
  • Language-name tags. Use hexadecimal LCIDs such as [$-407], which were understood in testing.
  • Translated formulas. Formulas in the file are always English.
  • Testing in one locale. Render under the target locales, as above, before sending.

Conclusion

Write numbers and dates as values with format codes: separators, the built-in short date and untagged month names follow each reader automatically. Pin month and day names with LCID tags such as [$-407] when the report's language is fixed, put currency symbols in bracketed blocks, translate labels from one table, and remember that formulas in files are always English. Render the workbook under each target locale with LibreOffice and LC_ALL to check it.

Frequently asked questions

How do I use a comma as decimal separator in Excel from Python? You don't need to. Write numbers as values with a format such as #,##0.00; Excel shows the reader's own decimal and thousands separators.

How do I show German month names regardless of the reader's settings? Prefix the date format with the German locale ID: $-407mmmm yyyy. French is $-40C, Polish $-415.

Which date format adapts to the reader's locale? The built-in short date, format number 14, which openpyxl exposes as mm-dd-yy. It displays as 10/5/2026 in the US and 05.10.2026 in Germany.

Do I need to translate formulas for other languages? No. Formulas are stored in English with comma separators in every file. Excel translates them for display.

More on number & date formats

More on number & date formats →