How-to
Formatting And Charting Excel Reports With PythonDeep dive

Format ZIP Codes and IDs with Leading Zeros in Excel

Write ZIP codes, employee numbers and account codes to Excel so 02134 stays 02134. Choose between storing them as text and storing numbers with a zero-padding format, write both ways with pandas, openpyxl and xlsxwriter, handle 5- and 9-digit ZIPs in one column, add prefixes and phone-style patterns, and avoid the lookup mismatches each choice can cause.

ZIP codes, employee numbers, account codes and part numbers are identifiers that happen to be written in digits, and many of them start with a zero. Pass them through a CSV file, a database integer column or a careless read_excel, and 02134 becomes the number 2134. When the report is written to Excel, there are two ways to get the zero back: store the code as text, or store the number and apply a number format that pads it. Both look the same in the cell, and both are right in different situations. This page, part of Applying Number and Date Formats in Excel, shows both with pandas, openpyxl and xlsxwriter.

For the reading side — stopping pandas from dropping the zeros in the first place — see Keep Leading Zeros When Reading Excel with pandas.

Two ways to show 02134 in a cell Two panels. Stored as text: the cell holds the string 02134, keeps every digit whatever the format, sorts as text and matches other text; Excel may show a number-stored-as-text warning. Stored as a number with the format 00000: the cell holds 2134 and displays 02134, sorts numerically and can be used in arithmetic, but a lookup against text codes fails. Text "02134" every digit kept, always matches other text codes number-as-text warning Number 2134, format 00000 stays numeric sorts as a number lookups against text fail

Prerequisites

Bash
pip install pandas openpyxl xlsxwriter

customers.csv has ZIP codes and employee numbers that were saved as plain numbers, so 02134 is now 2134 and 07030 is 7030. One customer has a 9-digit ZIP+4 code.

Step 1 — Store codes as text with zfill

When a code is an identifier — nobody adds ZIP codes together — text is the robust choice. Pad each value to its fixed width with str.zfill, and pandas writes strings as text cells:

Python
import pandas as pd

df = pd.read_csv("customers.csv")
print(df.dtypes.to_dict())

out = df.copy()
out["zip"] = out["zip"].astype(str).str.zfill(5)
out["employee"] = out["employee"].astype(str).str.zfill(6)
out.to_excel("codes_as_text.xlsx", index=False)

from openpyxl import load_workbook
ws = load_workbook("codes_as_text.xlsx").active
print([(ws.cell(r, 2).value, ws.cell(r, 2).data_type) for r in range(2, 6)])

The cells hold the strings 02134, 10001, 07030 — data type s, text. zfill pads to at least the width, so the 9-digit ZIP+4 stays as 21340042, which is not how ZIP+4 is written; the next steps handle it. Text codes show a small green triangle in Excel, the "number stored as text" warning, which Fix "Number Stored as Text" Warnings in Excel Output shows how to suppress. For template columns that people will type codes into, also set the number format to @ — Excel's Text format — on the empty cells: a code typed into a Text-formatted cell is stored as typed, zeros included, instead of being converted to a number the moment Enter is pressed.

Step 2 — Keep numbers and apply a padding format

When the column must stay numeric — a system that imports it expects numbers, or it is sorted numerically — write the numbers and give the cells a format that pads them. In a number format, 0 is a digit that is always shown, so 00000 shows at least five digits:

Python
with pd.ExcelWriter("codes_as_numbers.xlsx", engine="xlsxwriter") as writer:
    df.to_excel(writer, index=False, sheet_name="Customers")
    ws = writer.sheets["Customers"]
    zip5 = writer.book.add_format({"num_format": "00000"})
    emp = writer.book.add_format({"num_format": "000000"})
    ws.set_column("B:B", 12, zip5)
    ws.set_column("C:C", 12, emp)

check = load_workbook("codes_as_numbers.xlsx").active
print([(check.cell(r, 2).value, check.cell(r, 2).number_format) for r in range(2, 6)])

The cells hold 2134, 10001, 7030 as numbers and display 02134, 10001, 07030. Reading the file back with pandas gives the numbers, not the padded text — a number format changes only how a value is shown. In openpyxl the equivalent is cell.number_format = "00000" on each cell of the column.

Step 3 — Handle ZIP+4 and mixed-length columns

A 9-digit ZIP+4 code is written 02134-0042. The format 00000-0000 inserts the hyphen as a literal and pads the whole value to nine digits. When one column mixes 5-digit and 9-digit codes, a conditional section picks the pattern by value: [<=99999] applies the first format to values up to 99,999 and the second to everything else:

Python
from openpyxl import Workbook

wb = Workbook(); ws = wb.active
ws.append(["customer", "zip"])
for name, code in zip(df["customer"], df["zip"]):
    ws.append([name, int(code)])
for row in ws.iter_rows(min_row=2, min_col=2, max_col=2):
    row[0].number_format = "[<=99999]00000;00000-0000"
wb.save("zip_plus4.xlsx")
print([ws.cell(r, 2).number_format for r in range(2, 3)], [ws.cell(r, 2).value for r in range(2, 6)])

Opened in Excel, the column shows 02134, 10001, 07030 and 02134-0042. A text alternative is to build the string in pandas: pad to 5 or 9 digits and insert the hyphen for the long ones, s.str.zfill(9).str.replace(r"^(\d{5})(\d{4})$", r"\1-\2", regex=True) applied only to values longer than five digits.

The ZIP column with the mixed-length format The zip_plus4.xlsx sheet as Excel displays it. Acme shows 02134, Birch 10001, Corex 07030 and Dunmore 02134-0042, although the cells store the numbers 2134, 10001, 7030 and 21340042. zip_plus4.xlsx — displayed values A B C 1 customer zip (displayed) stored value 2 Acme 02134 2134 3 Birch 10001 10001 4 Corex 07030 7030 5 Dunmore 02134-0042 21340042

The stored values never change; only the display does. That is exactly what makes this approach safe for columns other systems read as numbers, and exactly what makes it unsuitable when someone will copy the displayed code into another application, which receives the stored number.

Padding formats and what they display A table of number formats. 00000 shows 2134 as 02134. 00000-0000 shows 21340042 as 02134-0042. The conditional format [<=99999]00000;00000-0000 shows 2134 as 02134 and 21340042 as 02134-0042 in the same column. "EMP-"000000 shows 42 as EMP-000042. [<=9999999]###-####;(###) ###-#### shows 4155550123 as (415) 555-0123. Stored value Displays 00000 2134 02134 00000-0000 21340042 02134-0042 [<=99999]00000;00000-0000 2134 / 21340042 02134 / 02134-0042 "EMP-"000000 42 EMP-000042 phone pattern 4155550123 (415) 555-0123

Step 4 — Add prefixes and patterns without changing the value

Number formats can add literal text, so a stored 42 can display as EMP-000042 while staying a sortable number, and a 10-digit number can display as a phone number. Text in double quotes is shown as it is:

Python
wb = Workbook(); ws = wb.active
ws.append(["employee", "phone"])
ws.append([42, 4155550123])
ws.append([30551, 2125550199])
for r in (2, 3):
    ws.cell(r, 1).number_format = '"EMP-"000000'
    ws.cell(r, 2).number_format = "[<=9999999]###-####;(###) ###-####"
wb.save("patterns.xlsx")
print([(ws.cell(r, 1).value, ws.cell(r, 1).number_format) for r in (2, 3)])

The first column shows EMP-000042 and EMP-030551; the second (415) 555-0123 and (212) 555-0199. The prefix is display only: filtering the column for "EMP-000042" in Excel finds nothing, because the cell contains 42. When people will search or copy the code as written, store the full text instead.

Step 5 — Keep lookups consistent

The choice matters most where sheets meet. A lookup — VLOOKUP, XLOOKUP, a pandas merge — compares stored values, not displayed ones. The text "02134" and the number 2134 formatted as 00000 look identical but never match:

Python
orders = pd.DataFrame({"zip": ["02134", "07030"], "orders": [12, 5]})
as_numbers = df[["customer", "zip"]]
as_text = df[["customer"]].assign(zip=df["zip"].astype(str).str.zfill(5))

try:
    as_numbers.merge(orders, on="zip")
except ValueError as exc:
    print("number vs text:", str(exc)[:80])
print("text vs text:  ", as_text.merge(orders, on="zip").to_dict("records"))

pandas refuses to merge an integer column with a text one, and Excel's lookup simply returns #N/A. Pick one representation for each kind of code across the whole workbook — text is the usual choice for identifiers — and convert at the point data enters the report. Codes longer than 15 digits must be text in any case, because Excel keeps only 15 significant digits of a number; see Fix Long ID Numbers Losing Digits in Excel.

Common pitfalls

  • Formatting when the value is already text. Number formats do nothing to text cells; pad the string instead.
  • zfill on ZIP+4 codes. It pads to a minimum width only; build the hyphenated form explicitly.
  • Expecting the format to change the value. A 00000 format still stores 2134; exports and lookups see 2134.
  • Mixing text and numbers across sheets. Lookups fail silently; use one representation.
  • Saving to CSV. CSV has no formats; the padded display is lost, and Excel strips zeros again on opening.
  • Long IDs as numbers. Beyond 15 digits they are rounded; store them as text.

Performance notes

astype(str).str.zfill() is vectorised and fast for any sheet size. Applying a format per column with xlsxwriter's set_column costs nothing per row; in openpyxl, setting number_format cell by cell is fine for tens of thousands of rows.

Conclusion

For identifiers, store codes as text padded with str.zfill, and suppress the number-as-text warning if it bothers readers. When the column must stay numeric, write numbers and apply 00000-style formats — 00000-0000 for ZIP+4, [<=99999]00000;00000-0000 for mixed lengths, and quoted literals for prefixes. Use the same representation in every sheet that is looked up against, and keep codes longer than 15 digits as text.

Frequently asked questions

How do I keep leading zeros when writing to Excel with Python? Either write the value as text — "02134" as a string, for example with str.zfill(5) — or write the number 2134 and give the cell the number format 00000. Both display 02134; text is safer for identifiers, the format for values that must stay numeric.

What number format shows a 9-digit ZIP code as ZIP+4? Use 00000-0000, which shows 21340042 as 02134-0042. For a column that mixes 5- and 9-digit codes, the conditional format <=9999900000;00000-0000 picks the right pattern for each value.

Should IDs be stored as text or numbers in Excel? As text, if nobody does arithmetic on them. Text keeps every digit, never switches to scientific notation and is unaffected by number formats. Store them as numbers only when a downstream system requires numeric IDs, and then apply a padding format.

Why does VLOOKUP fail on IDs with leading zeros? Because the two sides have different types: "02134" as text in one table and 2134 as a number with a 00000 format in the other look identical but do not match. Use the same storage choice in every sheet that is looked up against.

More on number & date formats

More on number & date formats →