How-to
Advanced Data Transformation And CleaningDeep dive

Visualise Missing Data Patterns in an Excel File

See where and why data is missing, not just how much: a missingness map sheet that shades every blank cell, the most common combinations of missing columns, missing rates by month and by source, columns that tend to go missing together, and a one-sheet Excel report built with pandas and openpyxl that shows a data owner where to look.

"12% of the values are missing" is the least useful thing you can say about missing data. What matters is the pattern. Blanks scattered at random through a column are a nuisance; blanks that all start in March point to a broken export; blanks that always occur in three columns together mean a whole form section was skipped; blanks concentrated in one branch's data mean one team is not filling something in. Each pattern has a different fix, and none of them is visible from a percentage. This page builds a missing-data report in Excel with pandas and openpyxl: a shaded map of every blank cell, the most frequent combinations of missing columns, missing rates over time and by source, and pairs of columns that go missing together. It belongs to Handling Missing Data in Excel Reports.

Prerequisites

Bash
pip install pandas openpyxl

orders.xlsx holds 240 orders from four branches between January and early October 2026, with ten columns. It has the kinds of gaps real exports have — and part of the point of this page is to find out what they are without being told.

Step 1 — The headline numbers, and why they are not enough

Start with the usual per-column count, so the more detailed views have something to explain:

Python
import pandas as pd

orders = pd.read_excel("orders.xlsx")
missing = orders.isna()
per_col = pd.DataFrame({"missing": missing.sum(), "share %": (missing.mean() * 100).round(1)})
print(per_col[per_col["missing"] > 0].sort_values("missing", ascending=False).to_string())
print(f"overall: {missing.to_numpy().mean():.1%} of cells blank")

Six columns have gaps, from 2% of Amount to 45% of Discount %, and about a tenth of all cells are blank. This tells you where to look, not why — Discount % being blank for almost half the rows could be people not entering it, a column that was retired, or a perfectly normal "no discount". The isna() frame built here is reused by every view below: one boolean per cell, True where the value is missing.

Step 2 — A missingness map sheet

The most direct picture is the data itself with every blank cell shaded. Writing 1 for missing and 0 for present, with conditional formatting on top, gives a map Excel users can scroll, filter and sort:

Python
from openpyxl import Workbook
from openpyxl.formatting.rule import CellIsRule
from openpyxl.styles import Alignment, Font, PatternFill

wb = Workbook()
ws = wb.active
ws.title = "Missing map"
cols = list(orders.columns)
ws.append(["Row", "Date", "Branch"] + cols)
for i, (row_missing, date, branch) in enumerate(zip(missing.itertuples(index=False), orders["Date"], orders["Branch"]), start=2):
    ws.append([i, date.date() if pd.notna(date) else None, branch] + [int(v) for v in row_missing])
last_col = ws.cell(1, ws.max_column).column_letter
ws.conditional_formatting.add(f"D2:{last_col}{ws.max_row}", CellIsRule(operator="equal", formula=["1"],
                              fill=PatternFill("solid", fgColor="D9534F"), font=Font(color="D9534F")))
ws.conditional_formatting.add(f"D2:{last_col}{ws.max_row}", CellIsRule(operator="equal", formula=["0"],
                              fill=PatternFill("solid", fgColor="E8EEF7"), font=Font(color="E8EEF7")))
for c in ws[1]:
    c.font = Font(bold=True)
    c.alignment = Alignment(text_rotation=90 if c.column > 3 else 0)
for col in range(4, ws.max_column + 1):
    ws.column_dimensions[ws.cell(1, col).column_letter].width = 3.2
ws.freeze_panes = "D2"
ws.auto_filter.ref = ws.dimensions
wb.save("missing_report.xlsx")
print("map:", ws.max_row - 1, "rows ×", len(cols), "columns")

Each data column becomes a narrow strip with a rotated header, red where a value is missing and pale blue where it is present; the font matches the fill so the 0s and 1s disappear into colour. The date and branch columns stay readable on the left, and the autofilter lets someone filter to one branch and watch its pattern. Scrolling down, the map shows a solid red band in Discount % from June onwards, three delivery columns that turn red on the same rows, and Sales rep gaps that appear only on some rows. These are patterns no percentage reveals.

A slice of the missingness map Rows are orders and columns are fields. Red cells are blank values. Discount is blank for every row from June. Delivery postcode, delivery date and courier are blank together on the same rows. Sales rep is blank mainly on Cardiff rows. A B C D E F G H 1 Date Branch Amount Discount Postcode Del. date Courier Rep 2 12 May Leeds 3 28 May Cardiff ■ ■ ■ ■ 4 03 Jun Leeds ■ 5 09 Jun Cardiff ■ ■ 6 17 Jun Bristol ■ ■ ■ ■ ■

Step 3 — The most common combinations of missing columns

Turning each row's missingness into a pattern string and counting them shows which columns go missing together:

Python
patterns = missing.apply(lambda r: " + ".join(c for c in cols if r[c]) or "(complete)", axis=1)
top = patterns.value_counts().head(8).rename_axis("Missing columns").to_frame("Rows")
top["share %"] = (top["Rows"] / len(orders) * 100).round(1)
print(top.to_string())

The output reads like a diagnosis. After the 95 complete rows, "Discount %" alone is the largest group — 86 orders from the June export change. "Delivery postcode + Delivery date + Courier" always appear together and never separately, which is the signature of a skipped section: these are the store-collection orders, which genuinely have no delivery details. Those gaps are not errors at all; they should be documented and probably given an explicit "Collected" value instead of blanks. Mixed combinations such as "Discount % + Delivery postcode + Delivery date + Courier" are simply two independent causes landing on the same row — a June order that was also collected in store. A combination that appears only once or twice is often a genuine data-entry slip worth fixing by hand.

Step 4 — Missing rate over time and by source

Many gaps are caused by when or where data was entered. A month-by-column table and a branch-by-column table of missing rates expose both:

Python
by_month = (missing.groupby(orders["Date"].dt.to_period("M")).mean() * 100).round(0)
by_branch = (missing.groupby(orders["Branch"]).mean() * 100).round(0)
show = ["Amount", "Discount %", "Delivery date", "Sales rep"]
print(by_month[show].astype(int).to_string())
print(by_branch[show].astype(int).to_string())

By month, Discount % jumps from 0% to 100% in June and stays there — something changed in the export, not in customer behaviour, and the fix is to ask whoever owns the export. By branch, Sales rep is missing on 68% of Cardiff orders and never elsewhere — a process issue in one branch, fixed by a conversation rather than by code. Amount's small, even gap rate across months and branches looks like genuine random omissions. The delivery columns, by contrast, show a steady 10–20% in every month and branch — consistent with a normal share of store collections. These tables go in the report next to the map so a data owner can see the cause at a glance.

Patterns and what they usually mean A column that becomes blank from a date onwards points to an export or system change. Columns that are blank together point to a skipped form section or a legitimately absent block. Blanks concentrated in one source point to a local process issue. Small, evenly spread gaps are usually random omissions. pattern likely cause Discount % 100% blank from June export changed Delivery ×3 blank together store collection, not an error Sales rep mostly Cardiff local process gap Amount ~4%, spread evenly random omissions

Step 5 — Columns that go missing together

For wider sheets, a correlation matrix of the missingness indicators summarises co-missing columns in one table. A value near 1 means two columns are almost always blank on the same rows:

Python
gaps = missing.loc[:, missing.any()].astype(int)
co = gaps.corr().round(2)
print(co.to_string())

with pd.ExcelWriter("missing_report.xlsx", engine="openpyxl", mode="a") as xw:
    per_col.to_excel(xw, sheet_name="By column")
    top.to_excel(xw, sheet_name="Patterns")
    by_month.astype(str).to_excel(xw, sheet_name="By month")
    by_branch.to_excel(xw, sheet_name="By branch")
    co.to_excel(xw, sheet_name="Co-missing")
with pd.ExcelFile("missing_report.xlsx") as book:
    print("sheets:", book.sheet_names)

The three delivery columns correlate perfectly (1.0) with each other, confirming the section pattern from Step 3; Discount % and Sales rep are unrelated to everything else. Converting the month periods to strings before writing avoids Excel showing them oddly. mode="a" appends the summary sheets to the workbook that already holds the map, so the whole picture arrives in one file. A colour scale on the co-missing sheet, as in Turn a Pivot Table into a Heatmap in Excel, makes strong pairs jump out.

From blank cells to a diagnosis The isna frame drives every view. The map shows where blanks are, the pattern counts show which columns are blank together, the month and branch tables show when and where, and the correlation sheet summarises co-missing columns, leading to a fix for each cause. isna() one flag per cell Map shaded blanks in Excel Patterns combinations, month, branch Fix by cause export, form, process

Common pitfalls

  • Reporting only percentages. They hide when, where and in which combinations data is missing.
  • Treating every blank as an error. Some blanks are legitimate; give them an explicit value.
  • Imputing before diagnosing. A column blank since an export change needs fixing at the source.
  • Huge maps. For very large files, map a sample or aggregate by period first.
  • Writing periods straight to Excel. Convert them to strings or dates first.

Conclusion

Missing data has patterns, and the patterns point to causes: an export that changed on a date, a form section that is skipped together, a branch that does not fill in a field, or genuine random omissions. Build the isna() frame once, then show it as a shaded map, as counted combinations of missing columns, as rates by month and by source, and as a co-missing correlation table — all in one Excel report a data owner can read. Fix each pattern at its cause before reaching for imputation.

Frequently asked questions

How do I see missing data patterns in Python? Build df.isna(), then count each row's combination of missing columns, group the flags by month or source to get missing rates, and correlate the flags to find columns that are blank together.

How do I highlight blank cells across a whole sheet in Excel? Write a 1 for missing and 0 for present for every cell and add conditional formatting that fills 1s in a strong colour. Matching the font colour to the fill hides the numbers.

How can I tell if missing values are random? If gaps are spread evenly across periods, sources and other columns they are likely random. Gaps that start on a date, cluster in one source or occur together in several columns have a specific cause.

What does it mean when several columns are always missing together? Usually a whole section was skipped or does not apply, such as delivery details for collected orders. Document it and use an explicit value rather than blanks.

More on missing data

More on missing data →