Guide
Automating Reporting WorkflowsDeep dive

Validate an Excel Report Before Sending It

The last gate before delivery — check the file exists and is a real workbook, the sheets and rows are there, totals are plausible, and this run agrees with the last one.

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.

Where output validation sits in the pipeline Generation produces the workbook, then validation checks it. If the checks pass the file is delivered. If they fail the previous good report stays in place, an alert is sent and the job exits non-zero. generate march.xlsx written validate the file opens · sheets · rows totals · versus last run pass fail deliver email, upload, copy to share block delivery keep the previous report alert with the reason · exit 1

Prerequisites

Bash
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:

Python
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:

Python
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:

Python
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.

Four layers of output validation, cheapest first Layer one checks the file exists, is large enough and opens. Layer two checks the sheets, columns and row counts. Layer three checks the numbers are plausible and reconcile. Layer four compares this run against recent history. 1 · file exists · size · opens as a workbook milliseconds 2 · shape sheets present · columns present · row floors one parse 3 · values totals positive · no negatives · summary reconciles arithmetic 4 · history rows and totals within a band of recent runs needs a record

Layer 4: compare against recent runs

Absolute thresholds are guesses; history is evidence. Keep a small record of each run and compare:

Python
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

Python
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":

Python
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:

Python
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

SymptomCauseFix
Empty report deliveredNo row floor on the main sheetAssert a minimum row count
Structurally perfect, semantically emptyA renamed source column produced all-NaNCheck for entirely blank columns
Summary disagrees with detailFilter applied in one place onlyReconcile the two totals with a tolerance
Alerts on every busy monthFixed absolute thresholdsCompare against a rolling median band
History becomes uselessFailed runs recorded tooRecord only after a successful delivery
Corrupt file passes the checksOnly size was checkedOpen the workbook as part of validation
Validation fails and the file is sent anywayChecks run after deliveryValidate 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:

A validation receipt in the log Four log lines record each check with its measured value: file size, sheets and rows, the reconciliation gap, and the comparison against the recent median. The final line records that delivery proceeded. INFO check=file ok size=412KB opens=yes INFO check=shape ok sheets=2 summary_rows=4 detail_rows=48211 INFO check=values ok total=1,284,600.00 reconciliation_gap=0.00 INFO check=history ok rows=48211 median=47950 band=50-150% INFO delivered to finance@example.com

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.

Up to the parent guide:

Related guides: