Validate an Excel Report Before Sending It
Every reporting team has a story about the month the numbers went out as zeros. The job ran, the file was produced, the email was sent, and nothing in the pipeline noticed. Output validation is the gate that catches it: a handful of checks on the artefact itself, run after generation and before delivery. This guide, part of Error Handling and Logging in Excel Automation, builds that gate.
Prerequisites
pip install pandas openpyxl
Layer 1: is it a real file?
Start with the cheapest checks. A zero-byte file, a file that is far smaller than usual, or one that cannot be opened at all are all caught before any parsing:
from pathlib import Path
from openpyxl import load_workbook
def check_file(path, min_bytes=5_000):
path = Path(path)
problems = []
if not path.exists():
return [f"{path} was not created"]
size = path.stat().st_size
if size < min_bytes:
problems.append(f"file is only {size:,} bytes (expected at least {min_bytes:,})")
try:
wb = load_workbook(path, read_only=True)
wb.close()
except Exception as exc: # zipfile.BadZipFile, InvalidFileException…
problems.append(f"file cannot be opened as a workbook: {type(exc).__name__}")
return problems
print(check_file("reports/march.xlsx"))
Opening the file is the check that catches a truncated write — a job killed mid-save leaves a .xlsx that is the right size and not a valid zip archive. Doing it in read_only mode keeps the check fast even on a large report.
Layer 2: the shape of the content
Then confirm the workbook contains what the recipient expects, sheet by sheet:
import pandas as pd
EXPECTED = {
"Summary": {"min_rows": 1, "columns": {"Region", "Revenue"}},
"Detail": {"min_rows": 10, "columns": {"Order_ID", "Region", "Revenue"}},
}
def check_content(path, expected=EXPECTED):
problems = []
book = pd.ExcelFile(path)
for sheet, rules in expected.items():
if sheet not in book.sheet_names:
problems.append(f"missing sheet {sheet!r}")
continue
frame = book.parse(sheet)
if len(frame) < rules["min_rows"]:
problems.append(
f"{sheet}: {len(frame)} row(s), expected at least {rules['min_rows']}"
)
missing = rules["columns"] - set(frame.columns)
if missing:
problems.append(f"{sheet}: missing column(s) {sorted(missing)}")
blank = [c for c in rules["columns"] & set(frame.columns) if frame[c].isna().all()]
if blank:
problems.append(f"{sheet}: column(s) entirely empty {sorted(blank)}")
return problems
The all-blank column check is the one that earns its keep on a mature pipeline. A renamed source column often survives every earlier stage — the merge produces NaN, the aggregation sums to zero, and the report is structurally perfect and semantically empty.
Layer 3: do the numbers make sense?
Structure can be right while the values are absurd. Assert the invariants your report is supposed to satisfy:
import pandas as pd
def check_numbers(path):
problems = []
summary = pd.read_excel(path, sheet_name="Summary")
detail = pd.read_excel(path, sheet_name="Detail")
if summary["Revenue"].sum() <= 0:
problems.append(f"total revenue is {summary['Revenue'].sum():,.2f}")
if (summary["Revenue"] < 0).any():
bad = summary.loc[summary["Revenue"] < 0, "Region"].tolist()
problems.append(f"negative revenue for {bad}")
# The summary must reconcile with the detail it claims to summarise
gap = abs(summary["Revenue"].sum() - detail["Revenue"].sum())
if gap > 0.01:
problems.append(f"summary and detail differ by {gap:,.2f}")
return problems
The reconciliation check is worth more than the others combined. A summary that does not add up to its own detail is the signature of a filter applied in one place and not the other — a bug that no structural check can see and that a reader will eventually find in a meeting.
Layer 4: compare against recent runs
Absolute thresholds are guesses; history is evidence. Keep a small record of each run and compare:
import csv
import statistics
from pathlib import Path
HISTORY = Path("state/report_history.csv")
def record_run(rows, total, path=HISTORY):
path.parent.mkdir(parents=True, exist_ok=True)
new = not path.exists()
with open(path, "a", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
if new:
writer.writerow(["rows", "total"])
writer.writerow([rows, round(total, 2)])
def check_against_history(rows, total, path=HISTORY, low=0.5, high=1.5, keep=6):
if not path.exists():
return []
with open(path, newline="", encoding="utf-8") as handle:
past = [(int(r["rows"]), float(r["total"])) for r in csv.DictReader(handle)][-keep:]
if len(past) < 3:
return [] # not enough history to judge
problems = []
median_rows = statistics.median(r for r, _ in past)
median_total = statistics.median(t for _, t in past)
if not (median_rows * low <= rows <= median_rows * high):
problems.append(f"row count {rows:,} is outside the usual range (~{median_rows:,.0f})")
if not (median_total * low <= total <= median_total * high):
problems.append(f"total {total:,.0f} is outside the usual range (~{median_total:,.0f})")
return problems
A median over the last six runs tolerates one odd month without becoming useless, and a 50–150% band is loose enough not to cry wolf on a genuinely busy quarter. Growing businesses drift upward, so re-reading the band from recent history rather than a constant keeps it honest without maintenance.
Wire it into the job
import sys
def publish(path, rows, total, to="finance@example.com"):
problems = (
check_file(path)
or check_content(path) + check_numbers(path) + check_against_history(rows, total)
)
if problems:
log.error("output validation failed for %s: %s", path, "; ".join(problems))
alert(f"Report NOT sent — {len(problems)} check(s) failed",
f"{path}\n\n" + "\n".join(f"• {p}" for p in problems))
return 1
deliver(path, to)
record_run(rows, total)
log.info("delivered %s rows=%d total=%.2f", path, rows, total)
return 0
sys.exit(publish("reports/march.xlsx", rows=48_211, total=1_284_600.00))
Note that record_run is only called after a successful delivery, so a bad run never poisons the history it will later be compared against. Failing before deliver also means the previous good report stays wherever it was published — recipients see last month's numbers rather than this month's zeros, which is the lesser of the two evils and, importantly, obvious.
Check what the reader will actually see
Some failures are invisible to a data check and obvious the moment a person opens the file. Three of them are worth automating, because they are the ones that get reported back as "the report looks broken":
from openpyxl import load_workbook
ERROR_VALUES = {"#REF!", "#DIV/0!", "#VALUE!", "#N/A", "#NAME?", "#NUM!"}
def check_presentation(path):
problems = []
wb = load_workbook(path, data_only=True)
for ws in wb.worksheets:
errors = [
f"{ws.title}!{cell.coordinate}"
for row in ws.iter_rows()
for cell in row
if isinstance(cell.value, str) and cell.value in ERROR_VALUES
]
if errors:
problems.append(f"{len(errors)} formula error(s), first at {errors[0]}")
# Columns narrower than their header text show as ####
for column, dim in ws.column_dimensions.items():
if dim.width and dim.width < 6:
problems.append(f"{ws.title}: column {column} is only {dim.width:.0f} wide")
if wb.active is None or wb.active.title != wb.sheetnames[0]:
problems.append("the workbook does not open on the first sheet")
return problems
Formula errors are the most damaging of the three, because a #REF! in a total makes every number on the page suspect even when the rest is correct. Narrow columns are cosmetic but corrosive — a reader who sees #### where a revenue figure should be does not think "column width", they think the report is broken. And a workbook that opens on the detail tab rather than the summary invites the wrong first impression.
Run this alongside the numeric checks rather than instead of them: it answers a different question, namely whether the artefact reads correctly, not whether it computes correctly.
Keep the previous good report
Blocking delivery only helps if there is something to fall back on. Publishing through a stable name plus a dated archive gives you that for free:
import shutil
from pathlib import Path
def publish_with_history(new_file, published="share/monthly_report.xlsx", archive="share/archive"):
new_file, published = Path(new_file), Path(published)
Path(archive).mkdir(parents=True, exist_ok=True)
if published.exists(): # keep what readers currently have
shutil.copy2(published, Path(archive) / f"{published.stem}_previous{published.suffix}")
shutil.copy2(new_file, published)
shutil.copy2(new_file, Path(archive) / new_file.name)
return published
Because the copy to published happens only after validation passes, a failed run leaves readers looking at last month's file — stale, clearly dated, and correct. That is nearly always better than a fresh file full of zeros, and the archived copy means a reader who needs the older numbers can still find them.
Common pitfalls and gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Empty report delivered | No row floor on the main sheet | Assert a minimum row count |
| Structurally perfect, semantically empty | A renamed source column produced all-NaN | Check for entirely blank columns |
| Summary disagrees with detail | Filter applied in one place only | Reconcile the two totals with a tolerance |
| Alerts on every busy month | Fixed absolute thresholds | Compare against a rolling median band |
| History becomes useless | Failed runs recorded too | Record only after a successful delivery |
| Corrupt file passes the checks | Only size was checked | Open the workbook as part of validation |
| Validation fails and the file is sent anyway | Checks run after delivery | Validate before the delivery step, always |
Report the checks, not just the failures
A gate that only speaks when it fails leaves everyone guessing whether it ran. Logging the passing checks with their numbers turns the gate into a receipt:
Five lines, all numbers, and any of them can be compared against last month at a glance. When someone asks whether the March figures were checked, the answer is a grep rather than a memory — and when a threshold eventually needs tuning, the log already contains the evidence for where to put it.
Performance and scale notes
The first layer is milliseconds, the second is one parse of the sheets you name, and the third is arithmetic on frames already in memory — for a normal report the whole gate costs a second or two. On a large report, keep the checks to the summary sheets and use nrows when reading detail, since you are testing plausibility rather than recomputing the report. History comparison reads a handful of lines from a CSV and is effectively free.
Conclusion
Output validation is four layers, cheapest first: the file opens, the sheets and columns are present with enough rows, the numbers are plausible and reconcile, and this run resembles recent ones. Run it after generation and before delivery, block the send when anything fails, keep the previous good report in place, alert with the specific reason, and record history only for runs that actually shipped.
Frequently asked questions
Is this not the same as validating the input? No. Input validation asks whether the source data is usable; output validation asks whether the artefact you just produced is worth sending. A pipeline can handle bad input flawlessly and still emit an empty report.
What is the single most valuable check? A row floor on the main sheet. Almost every embarrassing report failure is a file that came out empty or nearly so, and one comparison catches it.
How do I set a sensible threshold? From history. Keep the last few runs' row counts and totals, and flag anything outside a band such as 50–150% of the recent median.
What should happen when validation fails? Do not deliver. Keep the previous good file in place, alert with the specific reason, and exit non-zero so the scheduler records a failure.
Related
Up to the parent guide:
- Error Handling and Logging in Excel Automation — the failure classes and responses this gate belongs to.
Related guides:
- Retry a Failed Excel Report Job in Python — why a validation failure must never be retried.
- Validating Excel Data with Python — the input-side counterpart.
- Send an Excel Report to Multiple Recipients with Python — the delivery step this gate protects.
- Add a Summary Sheet to an Excel Report with Python — the sheet most of these checks target.