Guide
Formatting And Charting Excel Reports With PythonDeep dive

Format Numbers as Percentages in Excel with Python

A percentage format multiplies by 100 on display, so store 0.125 not 12.5 — the format strings, the factor-of-100 trap, negatives in red, and writing percentages from pandas.

A percentage cell in Excel is a number with a display rule: the format multiplies the stored value by 100 and appends a sign. That single fact explains the most common bug in generated reports — a margin column computed as 12.5 and formatted as a percentage, which Excel dutifully shows as 1250.0%. Store the fraction, format the display, and everything works, including the arithmetic readers do on top. This guide covers the format strings, the conversion, coloured negatives, and writing percentages from pandas. It extends Applying Number and Date Formats in Excel.

Why the stored value must be a fraction Two cells both carrying the number format 0.0 per cent. The first stores 0.125 and displays as 12.5 per cent, which is correct. The second stores 12.5, already expressed as a percentage, and the format multiplies it by one hundred again to display 1250.0 per cent. The rule is that a percentage format is a display multiplier, so the underlying value must always be the fraction. both cells carry number_format = "0.0%" stored as a fraction 0.125 12.5% stored already multiplied 12.5 1250.0%

Prerequisites

Bash
pip install openpyxl pandas xlsxwriter

Some rates to format, expressed as fractions:

Python
import pandas as pd

margins = pd.DataFrame({
    "region": ["North", "South", "West", "East"],
    "revenue": [5150.00, 4268.50, 3511.25, 2980.10],
    "cost": [4120.00, 3841.65, 3862.38, 2384.08],
})
margins["margin"] = (margins["revenue"] - margins["cost"]) / margins["revenue"]
print(margins["margin"].round(4).tolist())
# [0.2, 0.1, -0.1, 0.2]

Computing the rate as a fraction is the natural result of a division, which is why the bug appears only when somebody adds a * 100 to "make it a percentage".

Step 1 — Set the format with openpyxl

Python
from openpyxl import load_workbook

margins.to_excel("margins.xlsx", index=False)

wb = load_workbook("margins.xlsx")
ws = wb.active

for (cell,) in ws.iter_rows(min_row=2, min_col=4, max_col=4):
    cell.number_format = "0.0%"

ws.column_dimensions["D"].width = 12
wb.save("margins_formatted.xlsx")

The format strings you will use:

Format0.125 displays asUse for
0%13%headline rates
0.0%12.5%the everyday default
0.00%12.50%financial precision
0.0%;[Red]-0.0%red negativesvariance columns
+0.0%;-0.0%;0.0%explicit signchange columns

The zeros after the decimal point set the displayed precision only. The stored value is untouched, so a cell showing 12.5% may hold 0.1249876 — which matters when a reader sums a column and the total does not match the sum of what they can see. Round the values themselves when that reconciliation matters:

Python
margins["margin"] = margins["margin"].round(3)      # display and value agree

Step 2 — Colour and sign the variance columns

A variance column reads far better with signed, coloured values. Excel's format string has up to four sections separated by semicolons — positive, negative, zero and text:

Python
from openpyxl import load_workbook

wb = load_workbook("margins.xlsx")
ws = wb.active

# Positive in green with a plus, negative in red with a minus, zero plain.
VARIANCE = '[Color10]+0.0%;[Red]-0.0%;0.0%'

for (cell,) in ws.iter_rows(min_row=2, min_col=4, max_col=4):
    cell.number_format = VARIANCE

wb.save("margins_variance.xlsx")

Two notes on the colour syntax. Only a small set of named colours works — [Red], [Blue], [Green], [Black], [White], [Cyan], [Magenta], [Yellow] — and beyond those you use [ColorN] with an index into Excel's palette. Number-format colours are also independent of the font colour: a cell whose font is set to grey still renders red under [Red], because the format wins.

The negative section carries its own sign, which is why it reads -0.0% rather than 0.0%. Omitting the minus produces negatives that display as positives — a subtle and expensive formatting bug in a variance report.

Step 3 — Convert when the source is already multiplied

Data arriving from elsewhere is often already in percentage points. Convert on import, and be explicit about which convention each column uses.

Telling a fraction column from a percentage-point column Two sample columns. The first holds values such as 0.125 and 0.2, all with absolute value below one, which indicates fractions ready for a percentage format. The second holds 12.5 and 20.0, values above one, which indicates percentage points that must be divided by a hundred before formatting. A caution notes that a column of small rates such as 0.4 per cent is genuinely ambiguous and must be confirmed rather than guessed. 0.125 · 0.20 · -0.10 · 0.20 all below 1 in absolute value fractions — format directly no conversion needed 12.5 · 20.0 · -10.0 · 20.0 values above 1 percentage points — divide by 100 then format 0.4 is genuinely ambiguous: 0.4% or 40%? confirm the convention with the source; never guess on a column of small rates
Python
import pandas as pd

def to_fraction(series, already_multiplied=None, sample=200):
    """Return a rate column as fractions, converting if it is in percent points."""
    numbers = pd.to_numeric(series, errors="coerce")

    if already_multiplied is None:
        head = numbers.dropna().head(sample)
        # A column of fractions rarely exceeds 1 in absolute value.
        already_multiplied = bool(len(head)) and (head.abs() > 1).mean() > 0.5

    return numbers / 100 if already_multiplied else numbers

The heuristic is a convenience, not a decision procedure. A column of small rates — churn of 0.4%, a fee of 0.25% — sits entirely below 1 either way, and guessing wrong is a hundredfold error in a published number. Pass already_multiplied explicitly whenever you know, and treat the heuristic as something to confirm:

Python
rates = pd.read_excel("rates.xlsx")
rates["churn"] = to_fraction(rates["churn"], already_multiplied=True)

Text percentages need the sign stripping first, which is the same cleaning path as any other formatted number:

Python
import pandas as pd

def parse_percent_text(series):
    """Turn '12.5%' into 0.125."""
    text = series.astype("string").str.strip()
    is_percent = text.str.endswith("%", na=False)
    numbers = pd.to_numeric(text.str.rstrip("%"), errors="coerce")
    return numbers.where(~is_percent, numbers / 100)

The wider treatment is in converting Excel text columns to numbers.

Step 4 — Write percentages from pandas

Through ExcelWriter, set the format per column rather than per cell:

Python
import pandas as pd

def write_with_percentages(df, path, percent_columns, sheet_name="Report"):
    """Write a report, formatting the named columns as percentages."""
    with pd.ExcelWriter(path, engine="xlsxwriter") as writer:
        df.to_excel(writer, sheet_name=sheet_name, index=False)
        book, sheet = writer.book, writer.sheets[sheet_name]

        header = book.add_format({"bold": True, "bg_color": "#EEF2FF",
                                  "border": 1, "align": "center"})
        money = book.add_format({"num_format": "#,##0.00"})
        percent = book.add_format({"num_format": "0.0%"})
        variance = book.add_format({"num_format": '[Color10]+0.0%;[Red]-0.0%;0.0%'})

        for position, name in enumerate(df.columns):
            sheet.write(0, position, str(name), header)

            if name in percent_columns:
                fmt = variance if percent_columns[name] == "variance" else percent
                sheet.set_column(position, position, 13, fmt)
            elif pd.api.types.is_numeric_dtype(df[name]):
                sheet.set_column(position, position, 14, money)
            else:
                sheet.set_column(position, position, 16)

        sheet.freeze_panes(1, 0)
    return path

write_with_percentages(margins, "margins.xlsx",
                       percent_columns={"margin": "variance"})

Widths matter more for percentages than for most columns, because ##### appears the moment a formatted value does not fit — and a signed, coloured percentage is wider than the bare number suggests. The sizing helper in auto-fitting column widths sizes from the format string for exactly this reason.

Step 5 — Basis points and other scaled units

A number format has up to four sections A format string split by semicolons into four sections. The first formats positive values, the second negative ones, the third zero, and the fourth text. Each may carry its own colour in square brackets and its own literal characters, which is how a variance column shows green with a plus sign for gains and red with a minus for losses. Omitting the minus from the negative section makes losses display as gains. [Color10]+0.0% ; [Red]-0.0% ; 0.0% ; "n/a" 1 · positive [Color10]+0.0% +12.5% green, explicit plus 2 · negative [Red]-0.0% -4.2% the minus is yours to write 3 · zero 0.0% 0.0% plain, no colour 4 · text "n/a" n/a for non-numeric cells omit the minus in section 2 and every loss displays as a gain

There is no basis-point format. A basis point is a hundredth of a percent, so scale by ten thousand and use a plain number format with a literal suffix:

Python
from openpyxl import load_workbook

wb = load_workbook("rates.xlsx")
ws = wb.active

for (cell,) in ws.iter_rows(min_row=2, min_col=3, max_col=3):
    if isinstance(cell.value, (int, float)):
        cell.value = cell.value * 10_000        # 0.0125 -> 125
        cell.number_format = '0" bp"'           # displays: 125 bp

wb.save("rates_bp.xlsx")

Note the quoted literal in the format — 0" bp" appends the text without affecting the value. Using 0.0% here would rescale by a further hundred, which is the same trap in a different unit.

The general rule holds across every scaled unit: the value in the cell should be in the unit the format expects. A percentage format expects a fraction; a 0" bp" format expects the already-scaled basis-point number; a thousands format like #,##0, expects the raw value and divides for display.

Common pitfalls and fixes

SymptomCauseFix
12.5 shows as 1250.0%Value already multipliedStore the fraction, 0.125.
Negatives show without a signNegative section omits the minusWrite it as 0.0%;[Red]-0.0%.
Colour ignoredUnsupported colour nameUse [Red], [Blue], … or [ColorN].
Font colour overriddenNumber-format colour winsColour through the format, not the font.
Column shows #####Too narrow for a signed percentageWiden the column.
Total does not match the visible valuesDisplay rounded, value notRound the values themselves.
Basis points show as a percentage% rescales againUse 0" bp" on the scaled value.
Format lost after a pandas writeto_excel replaced the sheetFormat after writing.

Performance and scale notes

Number formats are style entries, so the guidance is the same as for any other formatting: assign one format object to many cells rather than constructing one per cell, and prefer a whole-column call where the engine offers it.

Python
import pandas as pd

# Constant cost, whatever the row count.
with pd.ExcelWriter("big.xlsx", engine="xlsxwriter") as writer:
    df.to_excel(writer, sheet_name="Rates", index=False)
    fmt = writer.book.add_format({"num_format": "0.0%"})
    writer.sheets["Rates"].set_column("D:D", 13, fmt)

Two arithmetic points worth knowing at scale. Compute the rate once, vectorised — a division over a column is a single pass, where a per-row loop is two orders of magnitude slower:

Python
# Fast
df["margin"] = (df["revenue"] - df["cost"]) / df["revenue"]

# Slow, and it also fails on a zero denominator
df["margin"] = df.apply(lambda r: (r["revenue"] - r["cost"]) / r["revenue"], axis=1)

Guard the denominator. A zero revenue produces inf rather than an exception, and inf formatted as a percentage displays as #DIV/0!-looking noise that readers report as a bug:

Python
import numpy as np

df["margin"] = np.where(
    df["revenue"] != 0,
    (df["revenue"] - df["cost"]) / df["revenue"].replace(0, np.nan),
    np.nan,
)

Leaving those rows as NaN writes an empty cell, which reads correctly as "not applicable" — far better than a spurious zero, and consistent with the missing-value handling in finding and reporting missing values.

Conclusion

A percentage format is a display multiplier, so the cell must hold the fraction — 0.125, not 12.5. Set 0.0% for the everyday case, and use the multi-section form [Color10]+0.0%;[Red]-0.0%;0.0% for variance columns so sign and direction read at a glance. Convert incoming percentage-point columns explicitly rather than relying on a heuristic, because a column of small rates is genuinely ambiguous and the error is a factor of a hundred. Round the values when readers will sum them, widen the column so a signed percentage does not turn into #####, and remember that basis points need a scaled value with a literal suffix rather than a percent sign.

Frequently asked questions

Why does my 12.5 display as 1250.0%? A percentage number format multiplies the stored value by 100 for display. Store the fraction — 0.125 — and the cell shows 12.5%. Storing the already-multiplied number multiplies it again.

Which format string should I use?"0.0%" for one decimal place, "0%" for whole percentages, and "0.00%" for two. The number of zeros after the point sets the displayed precision; the value itself is never rounded.

How do I show negatives in red with a sign? Use a two-section format separated by a semicolon, such as "0.0%;[Red]-0.0%". The first section formats positives and zero, the second formats negatives.

Should percentages be stored as fractions everywhere? In the spreadsheet, yes — Excel's percentage formatting depends on it, and any arithmetic in the sheet works correctly with fractions. Convert to a display number only when writing to a system that expects 12.5.

What about basis points? There is no built-in basis-point format. Multiply the fraction by ten thousand and use a plain number format with a "bp" suffix, such as 0" bp", since the percent sign would rescale it again.