How-to
Automating Reporting WorkflowsDeep dive

Lint and Type-Check an Excel Automation Project

Catch Excel-script bugs before they run — ruff for the mistakes that hide in report code (mutable defaults, inplace pandas calls, bare except, naive timestamps) and mypy with pandas-stubs and types-openpyxl for the ones that crash at runtime, such as read_excel returning a dict, wb.active possibly being None and cell values of mixed type. Every message shown was produced by the real tools.

Excel automation scripts fail in predictable ways: a sheet name changes and read_excel returns something else, a cell holds text where a number was expected, a default argument quietly accumulates values between runs. Most of these can be found without running anything. A linter catches the risky patterns, and a type checker — given type information for pandas and openpyxl — catches the calls that would crash. This guide, part of Testing and Packaging Excel Automation Scripts, runs both on a small report module, shows what they report and how to fix each finding, and wires them into the editor and CI.

Where the checks run Ruff runs in the editor as you type and fixes what it safely can. A pre-commit hook runs ruff and mypy before each commit. CI runs the same commands on every pull request, alongside the tests, so nothing unchecked reaches the main branch. Editor ruff on save Pre-commit ruff + mypy CI same commands, plus pytest Main branch only checked code

Prerequisites

Bash
pip install ruff mypy pandas-stubs types-openpyxl

pandas-stubs and types-openpyxl contain only type information. Without them mypy treats pandas and openpyxl as untyped and checks almost nothing about how you call them. Configure both tools in pyproject.toml:

Toml
[tool.ruff]
line-length = 100
src = ["src"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "B", "UP", "DTZ", "PD", "PTH", "SIM", "RUF"]

[tool.mypy]
python_version = "3.12"
files = ["src"]
warn_unused_ignores = true
disallow_untyped_defs = true
check_untyped_defs = true

The extra ruff rule sets matter for report code: B (bugbear) finds classic bugs, PD pandas pitfalls, DTZ timezone-naive timestamps and PTH string paths. disallow_untyped_defs makes mypy insist on annotated functions, which is what gives it something to check.

Step 1 — Run them on real report code

Here is a module that works on the author's machine most of the time:

Python
import datetime
import os
import pandas as pd
from openpyxl import Workbook, load_workbook


def load_orders(path: str) -> pd.DataFrame:
    orders = pd.read_excel(path, sheet_name=None)
    return orders.dropna(how="all")


def region_totals(orders: pd.DataFrame, regions=[]) -> pd.DataFrame:
    regions.append("Unknown")
    orders.fillna({"region": "Unknown"}, inplace=True)
    return orders.groupby("region")["amount"].sum().reset_index()


def write_summary(totals: pd.DataFrame, path: str) -> None:
    wb = Workbook()
    ws = wb.active
    ws.title = "Summary"
    ws["A1"] = f"Summary"
    ws["B1"] = datetime.datetime.now()
    for row in totals.itertuples(index=False):
        ws.append(list(row))
    wb.save(path)


def grand_total(path: str) -> float:
    ws = load_workbook(path, data_only=True)["Summary"]
    total = 0.0
    for (value,) in ws.iter_rows(min_row=2, min_col=2, max_col=2, values_only=True):
        total += value
    return total


def main() -> None:
    try:
        orders = load_orders("orders.xlsx")
        write_summary(region_totals(orders), "summary.xlsx")
    except:
        print("failed")
Bash
ruff check --output-format concise src
mypy

Ruff reported seven findings and mypy sixteen errors. The interesting ones:

What each tool found in the report module Ruff flagged the mutable default argument, inplace=True, the bare except and datetime.now without a time zone. mypy found that read_excel with sheet_name=None returns a dict that has no dropna, that wb.active may be None so title, indexing and append are unsafe, and that adding a cell value to a float fails for text, dates, formula objects and None. Tool and code Message Risk load_orders mypy attr-defined "dict[str, DataFrame]" has no attribute "dropna" crash on every run regions=[] ruff B006 mutable default argument values leak between calls inplace=True ruff PD002 inconsistent behaviour silent no-op on copies wb.active mypy union-attr Item None … has no attribute title crash if None total += value mypy operator float + str, date, None, … crash on a text cell except: ruff E722 bare except hides every failure

The first mypy error is a real bug: sheet_name=None reads every sheet into a dictionary, so .dropna would fail on the first run. mypy knows this because pandas-stubs gives read_excel separate signatures for a single sheet and for None.

Step 2 — Fix the findings

Each fix is small, and several make the code more honest about what it expects:

Python
import datetime as dt
import logging
from pathlib import Path

import pandas as pd
from openpyxl import Workbook, load_workbook
from openpyxl.worksheet.worksheet import Worksheet

log = logging.getLogger(__name__)


def load_orders(path: Path) -> pd.DataFrame:
    orders = pd.read_excel(path, sheet_name="Orders")
    return orders.dropna(how="all")


def region_totals(orders: pd.DataFrame, regions: list[str] | None = None) -> pd.DataFrame:
    orders = orders.fillna({"region": "Unknown"})
    if regions is not None:
        orders = orders[orders["region"].isin([*regions, "Unknown"])]
    return orders.groupby("region")["amount"].sum().reset_index()


def write_summary(totals: pd.DataFrame, path: Path) -> None:
    wb = Workbook()
    ws = wb.active
    if not isinstance(ws, Worksheet):
        raise TypeError("new workbook has no active worksheet")
    ws.title = "Summary"
    ws["A1"] = "Summary"
    ws["B1"] = dt.datetime.now(tz=dt.UTC).replace(tzinfo=None)
    for row in totals.itertuples(index=False):
        ws.append(list(row))
    wb.save(path)


def grand_total(path: Path) -> float:
    ws = load_workbook(path, data_only=True)["Summary"]
    total = 0.0
    for (value,) in ws.iter_rows(min_row=2, min_col=2, max_col=2, values_only=True):
        if not isinstance(value, (int, float)):
            raise ValueError(f"expected a number in column B, found {value!r}")
        total += value
    return total


def main() -> int:
    try:
        orders = load_orders(Path("orders.xlsx"))
        write_summary(region_totals(orders), Path("summary.xlsx"))
    except Exception:
        log.exception("summary failed")
        return 1
    return 0

wb.active is typed as "worksheet, chartsheet or None", so the isinstance check narrows it — after that line mypy accepts ws.title, ws["A1"] and ws.append. In grand_total, a cell value can be a number, text, date, time, formula object, rich text or None; mypy listed all of them when the code added value to a float. The isinstance check both satisfies mypy and turns a text cell into a clear error message. main now logs the exception and returns an exit code instead of printing "failed".

The timestamp fix needs one more step than ruff's DTZ005 message suggests. datetime.now(tz=dt.UTC) makes the time unambiguous, but openpyxl refuses to save it: TypeError: Excel does not support timezones in datetimes. The tzinfo in the datetime/time object must be set to None. Excel cells have no time zone, so decide which zone the report shows — UTC here — and drop the tzinfo after converting, as write_summary does with .replace(tzinfo=None). The error itself is covered in Fix "Excel does not support datetimes with timezones" in pandas. Both tools then passed, and a quick run confirmed the behaviour:

Python
import pandas as pd

orders = pd.DataFrame({"region": ["North", None, "South", "North"], "amount": [10.0, 5.0, 7.5, 2.5]})
totals = region_totals(orders)
write_summary(totals, Path("summary.xlsx"))
print(totals.to_dict("records"), grand_total(Path("summary.xlsx")))
# [{'region': 'North', 'amount': 12.5}, {'region': 'South', 'amount': 7.5}, {'region': 'Unknown', 'amount': 5.0}] 25.0

Step 3 — When the stubs are wrong

Type stubs describe the libraries imperfectly. The first version of the fix used orders.groupby("region", as_index=False)["amount"].sum(), which returns a DataFrame at runtime, but mypy reported Incompatible return value type (got "Series[Any]", expected "DataFrame") — the stubs do not model as_index=False. Three ways out, in order of preference:

  1. Use an equivalent spelling the stubs understand — here .sum().reset_index(), which is what the final code does.
  2. Use typing.cast(pd.DataFrame, …) when there is no such spelling, which documents the assumption.
  3. As a last resort, # type: ignore[return-value] with the specific error code. warn_unused_ignores then reports the comment once a stub update makes it unnecessary.

Avoid silencing whole modules with ignore_errors; the value of the checker is in the calls you did not think about.

How long the checks took on the example project Ruff checked the project in about 8 milliseconds. mypy took 6.3 seconds on the first run, mostly reading the pandas stubs, and 0.15 seconds on later runs with its cache. ruff check 0.008 s mypy, cold cache 6.29 s mypy, warm cache 0.15 s cache .mypy_cache in CI to keep the type check fast

Step 4 — Run the checks everywhere

Checks help only if they run without anyone remembering to. Ruff's editor integration flags problems as you type; ruff check --fix applies the safe fixes, such as sorting imports and removing unused ones. A pre-commit hook runs both tools before every commit:

Yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.8
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.3.1
    hooks:
      - id: mypy
        additional_dependencies: [pandas-stubs, types-openpyxl]

mypy in pre-commit runs in its own environment, so the stub packages must be listed in additional_dependencies, or the hook checks against untyped pandas and passes code the command line would reject. In CI, run ruff check, ruff format --check, mypy and pytest as separate steps so a failure says which one broke; Run a Python Excel Report in GitHub Actions shows a workflow to add them to.

Common pitfalls

  • Stubs out of step with the library. Pin pandas-stubs to a release matching your pandas version, or mypy reports functions that do not exist or misses ones that do.
  • Annotating everything as Any. It silences the checker and removes its value. Annotate DataFrame parameters as pd.DataFrame and let the stubs do the rest.
  • Types do not check columns. mypy knows an object is a DataFrame, not which columns it has. Validate columns at the boundary, for example with Validate Excel Data with pandera Schemas.
  • Turning everything on at once in an old project. Hundreds of findings get ignored. Start with the defaults plus B and PD, fix them, then add rule sets one at a time.

Conclusion

Add ruff with the bugbear, pandas, timezone and pathlib rules, and mypy with pandas-stubs and types-openpyxl. On a small report module they found a guaranteed crash from read_excel(sheet_name=None), unsafe uses of wb.active and cell values, a leaking mutable default and a bare except. Narrow openpyxl's unions with isinstance, work around stub gaps with equivalent code or cast, and run both tools in the editor, in pre-commit and in CI.

Frequently asked questions

How do I type-check pandas and openpyxl code? Install mypy with pandas-stubs and types-openpyxl, then run mypy on your source folder. The stubs describe what each function returns, so mypy can tell, for example, that read_excel(..., sheet_name=None) returns a dict of DataFrames rather than a DataFrame.

Why does mypy say wb.active might be None? In openpyxl's type stubs, Workbook.active can be a worksheet, a chartsheet or None. Narrow it with if not isinstance(ws, Worksheet) raise, and mypy then accepts ws.title, ws"A1" and ws.append.

Why can't I add a cell value to a number without a type error? A cell value can be a number, text, date, time, formula object, rich text or None. mypy lists every one of those when you add it to a float. Check isinstance(value, (int, float)) first, which also turns a text cell into a clear error instead of a TypeError.

Which ruff rules are useful for pandas report scripts? Beyond the defaults, B (bugbear) catches mutable default arguments, PD flags inplace=True and other pandas pitfalls, DTZ flags timezone-naive datetime.now(), PTH suggests pathlib, and I sorts imports. They ran in about 8 milliseconds on the example.

More on logging, testing & packaging

More on logging, testing & packaging →