Apply Conditional Formatting with xlsxwriter
A colour written into a cell is a fact about the moment the file was generated. A conditional format is a rule stored in the workbook, which Excel re-evaluates every time the values change — so a reader who edits a figure, filters the table or pastes in next month's numbers still sees the right cells highlighted.
That difference is why conditional formatting is worth reaching for in generated reports rather than computing the colours in pandas. This guide covers xlsxwriter's rule types, the whole-row formula rule that most reports end up wanting, and the reference-anchoring detail that decides whether it works. It is part of Building Excel Reports with xlsxwriter.
Prerequisites
pip install xlsxwriter pandas
The examples build their own workbook, so they run as written. Everything applies equally when xlsxwriter is driven through pd.ExcelWriter(engine="xlsxwriter").
Step 1: Set up a sheet to format
import pandas as pd
import xlsxwriter
df = pd.DataFrame({
"region": ["North", "South", "East", "West", "Central"],
"amount": [150.25, 274.75, 75.0, 190.4, 88.1],
"variance": [0.041, -0.062, -0.128, 0.012, -0.005],
"status": ["ok", "watch", "breach", "ok", "ok"],
})
wb = xlsxwriter.Workbook("flagged.xlsx")
ws = wb.add_worksheet("Regions")
header = wb.add_format({"bold": True, "bg_color": "#1F4E78",
"font_color": "white", "border": 1})
ws.write_row(0, 0, ["Region", "Amount", "Variance", "Status"], header)
for i, row in enumerate(df.itertuples(index=False), start=1):
ws.write_string(i, 0, row.region)
ws.write_number(i, 1, row.amount)
ws.write_number(i, 2, row.variance)
ws.write_string(i, 3, row.status)
ws.set_column("A:A", 14)
ws.set_column("B:B", 14, wb.add_format({"num_format": '#,##0.00'}))
ws.set_column("C:C", 12, wb.add_format({"num_format": "0.0%"}))
ws.set_column("D:D", 12)
LAST = len(df) # last data row, zero-based
Step 2: Cell and text rules
A cell rule compares each cell in the range against a literal:
bad = wb.add_format({"bg_color": "#FFC7CE", "font_color": "#9C0006"})
good = wb.add_format({"bg_color": "#C6EFCE", "font_color": "#006100"})
ws.conditional_format(1, 2, LAST, 2, {
"type": "cell", "criteria": "<", "value": -0.05, "format": bad,
})
ws.conditional_format(1, 2, LAST, 2, {
"type": "cell", "criteria": ">=", "value": 0.0, "format": good,
})
ws.conditional_format(1, 3, LAST, 3, {
"type": "text", "criteria": "containing", "value": "breach", "format": bad,
})
Rules are evaluated in the order they are added, and — unlike some spreadsheet behaviour people expect — several can apply to the same cell, with later rules layering on top for any attribute the earlier ones did not set. Where two rules genuinely conflict, add "stop_if_true": True to the first so the second is skipped.
The row and column form (conditional_format(first_row, first_col, last_row, last_col, options)) is worth preferring to the string form ("C2:C6") for the same reason chart ranges are: it is computed from LAST, so it stays correct when the data grows.
Step 3: Colour scales, data bars and icon sets
Where there is no agreed threshold, show the shape of the data instead:
ws.conditional_format(1, 1, LAST, 1, {
"type": "3_color_scale",
"min_color": "#FFC7CE", "mid_color": "#FFEB9C", "max_color": "#C6EFCE",
})
ws.conditional_format(1, 1, LAST, 1, {
"type": "data_bar",
"bar_color": "#5B5CF0",
"bar_solid": True,
"bar_only": False, # True hides the number and shows only the bar
"data_bar_2010": True, # the newer bar style, incl. negative handling
})
ws.conditional_format(1, 2, LAST, 2, {
"type": "icon_set",
"icon_style": "3_arrows",
"icons": [{"criteria": ">=", "type": "number", "value": 0.02},
{"criteria": ">=", "type": "number", "value": -0.02}],
})
A data bar and a colour scale on the same range is a legitimate combination — the bar shows magnitude, the fill shows position — but it is also the fastest way to make a table look like a toy. Pick one per column.
data_bar_2010: True opts into the later data-bar specification, which draws negative values from a midpoint rather than from the left edge. Without it, a column containing negatives renders in a way most readers misread.
Step 4: Highlight an entire row from one column
This is the rule most reports actually want, and the one that most often comes out wrong. The rule applies to the whole table's range, but its formula must always test the same column while moving down the rows:
row_flag = wb.add_format({"bg_color": "#FEE8F2"})
ws.conditional_format(1, 0, LAST, 3, {
"type": "formula",
"criteria": '=$D2="breach"', # $D anchors the column, 2 is the anchor row
"format": row_flag,
})
Two things have to line up. The formula is written relative to the top-left cell of the range — here A2 — so the row number in the formula is 2, not 1 and not $2. And the column must carry a $: without it, the rule tests column A in column A, column B in column B, and so on, which produces a scatter of highlighted cells that looks almost right and is not.
Step 4b: Order the rules deliberately
Rules are stored in the order they are added, and Excel applies them all — later ones layering over earlier ones for any attribute the earlier ones left unset. That is useful for building up a look from small rules, and it is the reason a "why is that cell amber when it should be red" question usually has a boring answer:
The habit that avoids the question entirely is to add rules from most serious to least, with "stop_if_true": True on any that should be final. Excel's own dialog exposes the same ordering, so a reader who opens the rules manager sees exactly the sequence your script wrote.
Step 5: Rules that reference other cells
Because the criteria is an ordinary Excel formula, a threshold can live in a cell rather than in your code — which lets a reader change it without regenerating the file:
ws.write("F1", "Threshold")
ws.write_number("F2", -0.05, wb.add_format({"num_format": "0.0%",
"bg_color": "#FDEFD8"}))
ws.conditional_format(1, 2, LAST, 2, {
"type": "formula",
"criteria": "=$C2<$F$2", # both anchored: column C by row, F2 absolutely
"format": bad,
})
wb.close()
$F$2 is fully absolute so every row compares against the same threshold cell, while $C2 keeps its row relative so it walks down the column. Handing the threshold to the reader like this turns a hard-coded report into one they can explore — and it is the kind of small affordance that stops people exporting your output into their own spreadsheet.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Only part of a row highlights | Column not anchored in the formula | Use $D2, not D2 |
| Nothing highlights | Formula row does not match the range's first row | Range starting at row 2 → formula says 2 |
| Every row highlights | Row anchored as well: $D$2 | Leave the row relative |
| Two rules fight | Both apply, later one layers on | Add "stop_if_true": True to the first |
| Negative data bars look wrong | Old bar specification | "data_bar_2010": True |
| Colours ignored in LibreOffice | Icon set or bar style unsupported there | Prefer cell rules and colour scales for portability |
| The file grows and opens slowly | One rule written per cell in a loop | One rule over the whole range |
| Rule lost after pandas wrote the sheet | Applied before to_excel wrote the cells | Apply rules after the data is written |
Performance and scale notes
The rule count matters, not the cell count: a single rule over A2:D100000 costs almost nothing, while a hundred thousand single-cell rules bloat the file and make Excel slow to open. If you find yourself in a loop calling conditional_format per row, the rule you want is a formula rule over the whole range.
Colour scales and data bars over very large ranges are also computed by Excel on every recalculation, so on a sheet with hundreds of thousands of rows they are noticeably heavier than a plain cell rule. On a detail sheet that large, put the visual emphasis on the summary and leave the detail plain — which is usually better reporting anyway.
Conclusion
Conditional formatting keeps a generated report honest: the highlight belongs to the rule, not to the moment the file was written, so it stays right when a reader edits or filters. Use cell rules where a threshold is agreed, colour scales and data bars where the distribution is the message, and a formula rule with $ on the column when one field should light the whole row. Apply one rule per range rather than per cell, and put the threshold in a cell when it is something readers should be able to change.
Frequently asked questions
Why does my whole-row rule highlight the wrong rows?
The column reference in the formula is not anchored. Write $D5 — dollar on the column, none on the row — so every column in the row tests the same cell while the row number still moves down.
Which cell does the formula refer to? The top-left cell of the range you passed. Excel rewrites the reference for every other cell relative to that anchor, which is why the formula and the range must agree.
Can I use a fill colour that is not one of Excel's presets?
Yes. Pass any hex colour to add_format with bg_color and font_color. The classic red-amber-green trio Excel offers is only a convention.
Does conditional formatting slow down a large workbook? One rule over a large range is cheap. Thousands of separate single-cell rules are not — apply one rule to the whole range instead of looping over cells.
Related
Up to the parent guide:
- Building Excel Reports with xlsxwriter — where these rules fit in the write-once model.
Related guides:
- Applying Conditional Formatting with openpyxl — the same rules on the engine that can edit an existing file.
- openpyxl: Apply Conditional Formatting to a Range — range strings and multi-block ranges.
- Write a Formatted Excel Report with xlsxwriter — the table these rules are applied to.
- Highlight Invalid Cells in Excel with Python — flagging validation failures rather than thresholds.