Excel Spreadsheets (.xlsx) skill for Claude Code

Live formulas, real number formats, zero #REF!. Claude writes workbooks a finance analyst would trust: real dates and numbers with formats, totals as auditable formulas, frozen headers, tables, dropdown validation and charts. It knows openpyxl never calculates, so it never saves over a data_only load, flags formulas without cached values, and runs a bundled audit for error cells and external links before reporting numbers.

  • namexlsx-spreadsheets
  • size207 lines · ≈2,200 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Creates, edits, and audits Excel .xlsx workbooks with openpyxl (Python) or exceljs (Node) — live formulas instead of pasted values, number formats, styles, column widths, freeze panes, tables, data validation, conditional formatting, charts, large-file streaming, and the recalculation caveat that libraries do not compute formulas. Use when the user wants an Excel file generated from data, a workbook updated or reformatted, formulas or dropdowns added, values extracted from .xlsx, or a spreadsheet checked for #REF! and broken formulas.

Try asking

  • Turn this CSV of orders into a formatted Excel report with totals and a chart
  • Add a region dropdown and highlight negative margins in our pricing workbook
  • Check this financial model for broken formulas before I send it to the board

Use it when

  • An Excel deliverable must be generated from data
  • An existing workbook needs inputs, formatting, or validation changed
  • Values or tables must be read out of .xlsx files
  • A workbook needs checking for error values and stale calculations

Skip it when

  • The work is data analysis rather than producing a workbook — use pandas-analysis instead
  • The input is a messy CSV that needs cleaning first — use csv-cleaning instead

How to check the result

The audit script reports no error values, any formulas without cached values are recalculated or disclosed, and key totals match an independent calculation in Python.

The common mistake it prevents

Loading a workbook with data_only=True and saving it, which permanently replaces every formula with its last cached value or with nothing at all.

~/.claude/skills/xlsx-spreadsheets/SKILL.md

--- name: xlsx-spreadsheets description: Creates, edits, and audits Excel .xlsx workbooks with openpyxl (Python) or exceljs (Node) — live formulas instead of pasted values, number… license: MIT ---

Done well means: numbers are stored as numbers with number formats (not strings), totals and derived values are live formulas a user can audit, the sheet is readable on open (header styling, widths, frozen header, filters), nothing the user did not ask to change was lost, and you verified the workbook has no error values and no uncalculated formulas before claiming results.

Setup and tool choice

pip install openpyxl          # read/write .xlsx (no calculation engine)
npm i exceljs                 # Node alternative
# optional: LibreOffice (soffice) to recalculate and render
TaskUse
Create or edit with formatting, formulas, validation, chartsopenpyxl
Analysis of tabular data, then exportpandas (df.to_excel(..., engine="openpyxl")), then style with openpyxl
Very large output (100k+ rows)openpyxl Workbook(write_only=True) or xlsxwriter (write-only, fast, rich formatting)
Node/TypeScript servicesexceljs (streaming writer available)
Read .xls (legacy)xlrd (xls only) or convert with LibreOffice first
CSV in, messy encodingsClean first (see csv-cleaning), then write xlsx

Step 1: audit before and after

python3 scripts/xlsx_audit.py model.xlsx
python3 scripts/xlsx_audit.py model.xlsx --json > audit.json

Reports every sheet (visibility, used range, formula count, merged ranges, validations, conditional formats, tables, freeze panes), cells holding error values (#REF!, #DIV/0!, #NAME?, #VALUE!, #N/A, #SPILL!), formulas with no cached value, external workbook references, and defined names. Exit code 1 if any errors or uncalculated formulas exist.

The recalculation caveat

openpyxl and exceljs write formula text; they do not calculate. Consequences:

  • A file you just wrote has formulas with no cached values. Excel calculates them on open; but anything reading the file with data_only=True (or pandas) sees None.
  • Loading with data_only=True and saving replaces formulas with their last values permanently. Never save a workbook loaded with data_only=True over the original.
  • To get computed values without Excel: recalculate with LibreOffice headless (open and re-save via a conversion such as soffice --headless --convert-to xlsx --outdir recalculated/ model.xlsx), then re-run the audit to confirm cached values exist. Alternatively compute the numbers in Python and write both the value-based check and the formula.
  • Tell the user explicitly when the delivered file has not been recalculated.

Create a well-formed sheet

from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.table import Table, TableStyleInfo

wb = Workbook()
ws = wb.active
ws.title = "Sales"

headers = ["Date", "Region", "Units", "Unit price", "Revenue"]
ws.append(headers)
for r in rows:                                     # r = (date_obj, "EMEA", 12, 19.99)
    ws.append([*r, None])
last = ws.max_row
for i in range(2, last + 1):
    ws[f"E{i}"] = f"=C{i}*D{i}"                    # live formula per row

ws[f"D{last + 2}"] = "Total"
ws[f"E{last + 2}"] = f"=SUM(E2:E{last})"
ws[f"D{last + 2}"].font = Font(bold=True)

# Formats: store real dates and numbers; format them
for i in range(2, last + 3):
    ws[f"A{i}"].number_format = "yyyy-mm-dd"
    ws[f"D{i}"].number_format = "#,##0.00"
    ws[f"E{i}"].number_format = "#,##0.00"

# Header style, widths, freeze, filter
for cell in ws[1]:
    cell.font = Font(bold=True, color="FFFFFF")
    cell.fill = PatternFill("solid", fgColor="305496")
    cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for idx, width in enumerate([12, 12, 8, 12, 14], start=1):
    ws.column_dimensions[get_column_letter(idx)].width = width
ws.freeze_panes = "A2"

# An Excel Table gives filters, banding, and structured references
table = Table(displayName="SalesTable", ref=f"A1:E{last}")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True)
ws.add_table(table)

wb.save("sales.xlsx")

Rules:

  • Formulas use English function names and comma separators in the file format regardless of the user’s locale (=SUM(A1:A3), not =SUMME(A1;A3)).
  • Newer dynamic-array functions (FILTER, UNIQUE, XLOOKUP, LET) may need the _xlfn. prefix when written by libraries (=_xlfn.XLOOKUP(...)), or Excel shows #NAME?. Prefer classic functions when compatibility matters.
  • Column widths are in character units; there is no auto-fit. Estimate from the longest value: min(max(len(str(v)) for v in column) + 2, 60).
  • Do not use an auto filter on a range that is also a Table; the Table has its own.
  • Dates: write datetime.date/datetime objects, not strings. Excel has no time zones — convert to the intended local time and drop tzinfo first (openpyxl rejects aware datetimes).
  • Leading zeros (ZIP codes, IDs): write as strings and set number_format = "@".
  • Money: keep Decimal or integer cents in your code; write floats only at the end.

Validation, conditional formatting, charts

from openpyxl.chart import BarChart, Reference
from openpyxl.formatting.rule import CellIsRule
from openpyxl.worksheet.datavalidation import DataValidation

dv = DataValidation(type="list", formula1='"EMEA,APAC,AMER"', allow_blank=False,
                    showErrorMessage=True, errorTitle="Invalid region", error="Pick from the list")
ws.add_data_validation(dv)
dv.add(f"B2:B{last}")
# Lists longer than 255 characters must reference a range: formula1="=Lists!$A$1:$A$40"

ws.conditional_formatting.add(f"E2:E{last}",
    CellIsRule(operator="lessThan", formula=["0"], font=Font(color="9C0006"),
               fill=PatternFill("solid", fgColor="FFC7CE")))

chart = BarChart()
chart.title = "Revenue by row"
chart.add_data(Reference(ws, min_col=5, min_row=1, max_row=last), titles_from_data=True)
chart.set_categories(Reference(ws, min_col=2, min_row=2, max_row=last))
ws.add_chart(chart, "G2")

Edit an existing workbook

from openpyxl import load_workbook
wb = load_workbook("model.xlsx")                 # formulas preserved; NOT data_only
ws = wb["Inputs"]
ws["B3"] = 0.07                                   # change an input, keep formulas downstream
wb.save("model-updated.xlsx")                     # new name; keep the original

What openpyxl loses or may alter on load/save — check before editing a user’s file:

  • Shapes, text boxes, SmartArt, form controls, slicers, and pivot table caches may be dropped or degraded; images and charts are partially supported.
  • VBA is removed unless you load with keep_vba=True and save as .xlsm.
  • External links and complex conditional formats can change. If the workbook has these, edit a copy, compare, and tell the user what was not preserved. For heavy-feature workbooks, consider writing a separate output sheet/file instead.

Read values for analysis:

wb = load_workbook("report.xlsx", data_only=True, read_only=True)   # cached values, fast
for row in wb["Data"].iter_rows(min_row=2, values_only=True):
    ...
wb.close()

exceljs (Node)

import ExcelJS from "exceljs";
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Sales", { views: [{ state: "frozen", ySplit: 1 }] });
ws.columns = [
  { header: "Region", key: "region", width: 12 },
  { header: "Revenue", key: "revenue", width: 14, style: { numFmt: "#,##0.00" } },
];
ws.addRows(data);
ws.getCell(`B${data.length + 2}`).value = { formula: `SUM(B2:B${data.length + 1})` };
ws.getRow(1).font = { bold: true };
await wb.xlsx.writeFile("sales.xlsx");

Supplying result in a formula cell ({ formula, result }) sets a cached value; only do it when you computed that result correctly yourself.

Large files

  • Write: Workbook(write_only=True); append rows; styles via WriteOnlyCell.
  • Read: load_workbook(path, read_only=True); iterate with values_only=True; call wb.close().
  • Excel’s limit is 1,048,576 rows × 16,384 columns per sheet; split or use CSV/Parquet beyond that.

Verification

  • scripts/xlsx_audit.py exits 0, or every uncached formula is explained by “not yet recalculated” and the user is told; zero error values.
  • Spot-check key numbers: compute the expected total in Python and compare with the formula’s cached value after recalculation.
  • Open-check: render to PDF with LibreOffice (soffice --headless --convert-to pdf) or ask the user to open it; confirm headers, formats, and frozen panes look right.
  • For edits, compare the audit of the original and the output: same sheets, formulas, and validations except for intended changes.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/xlsx_audit.py 127 lines · 5.3 KB
#!/usr/bin/env python3
"""Audit an .xlsx workbook: sheets, formulas, missing cached values, error cells,
external links, defined names, validations, merged ranges, and hidden sheets.

Requires: pip install openpyxl
Usage:
  python3 xlsx_audit.py model.xlsx
  python3 xlsx_audit.py model.xlsx --json
  python3 xlsx_audit.py model.xlsx --max-list 50

Exit codes: 0 = no error values and every formula has a cached value,
            1 = error values (#REF!, #DIV/0!, ...) or uncalculated formulas found,
            2 = cannot open file / missing dependency.
"""
import argparse
import json
import sys

try:
    import openpyxl
except ImportError:
    print("error: openpyxl is required: pip install openpyxl", file=sys.stderr)
    sys.exit(2)

ERRORS = {"#REF!", "#DIV/0!", "#NAME?", "#VALUE!", "#N/A", "#NUM!", "#NULL!", "#SPILL!", "#CALC!"}


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("xlsx")
    ap.add_argument("--json", action="store_true")
    ap.add_argument("--max-list", type=int, default=20, help="max cells listed per category")
    args = ap.parse_args()

    try:
        wb_f = openpyxl.load_workbook(args.xlsx, data_only=False)   # formulas
        wb_v = openpyxl.load_workbook(args.xlsx, data_only=True)    # cached values from last calc
    except Exception as exc:  # noqa: BLE001
        print(f"error: cannot open {args.xlsx}: {exc}", file=sys.stderr)
        return 2

    sheets, total_formulas, total_uncached, total_errors = [], 0, 0, 0
    for ws in wb_f.worksheets:
        wv = wb_v[ws.title]
        formulas, uncached, errors, ext_refs = [], [], [], []
        for row in ws.iter_rows():
            for cell in row:
                v = cell.value
                is_formula = isinstance(v, str) and v.startswith("=") or cell.data_type == "f"
                cached = wv[cell.coordinate].value
                if is_formula:
                    formulas.append(cell.coordinate)
                    if cached is None:
                        uncached.append(cell.coordinate)
                    if "[" in str(v):
                        ext_refs.append(f"{cell.coordinate}: {str(v)[:60]}")
                val = cached if is_formula else v
                if isinstance(val, str) and val.strip() in ERRORS:
                    errors.append(f"{cell.coordinate}={val.strip()}")
        total_formulas += len(formulas)
        total_uncached += len(uncached)
        total_errors += len(errors)
        sheets.append({
            "name": ws.title,
            "state": ws.sheet_state,
            "dimensions": ws.dimensions,
            "max_row": ws.max_row,
            "max_col": ws.max_column,
            "formulas": len(formulas),
            "uncached_formulas": uncached[: args.max_list],
            "uncached_count": len(uncached),
            "errors": errors[: args.max_list],
            "error_count": len(errors),
            "external_refs": ext_refs[: args.max_list],
            "merged_ranges": len(ws.merged_cells.ranges),
            "data_validations": len(ws.data_validations.dataValidation),
            "conditional_formats": len(ws.conditional_formatting),
            "freeze_panes": ws.freeze_panes,
            "auto_filter": ws.auto_filter.ref,
            "charts": len(getattr(ws, "_charts", [])),
            "tables": list(ws.tables.keys()),
        })

    defined = {}
    try:
        for name, dn in wb_f.defined_names.items():
            defined[name] = dn.attr_text
    except AttributeError:  # very old openpyxl
        pass

    report = {
        "file": args.xlsx,
        "sheets": sheets,
        "defined_names": defined,
        "external_links": len(getattr(wb_f, "_external_links", [])),
        "totals": {"formulas": total_formulas, "uncached_formulas": total_uncached, "error_cells": total_errors},
    }

    if args.json:
        print(json.dumps(report, indent=2, default=str))
    else:
        print(report["file"])
        for s in sheets:
            hidden = "" if s["state"] == "visible" else f" [{s['state']}]"
            print(f"  sheet '{s['name']}'{hidden}: {s['dimensions']} formulas={s['formulas']} "
                  f"merged={s['merged_ranges']} validations={s['data_validations']} "
                  f"cond_formats={s['conditional_formats']} tables={s['tables']} freeze={s['freeze_panes']}")
            if s["error_count"]:
                print(f"    ERROR values ({s['error_count']}): {s['errors']}")
            if s["uncached_count"]:
                print(f"    formulas without cached value ({s['uncached_count']}): {s['uncached_formulas']}")
            if s["external_refs"]:
                print(f"    external references: {s['external_refs']}")
        if defined:
            print(f"  defined names: {defined}")
        if report["external_links"]:
            print(f"  external workbook links: {report['external_links']}")
        t = report["totals"]
        print(f"  totals: formulas={t['formulas']} uncached={t['uncached_formulas']} errors={t['error_cells']}")
        if t["uncached_formulas"]:
            print("  note: uncached formulas were written by a library and never calculated; recalculate "
                  "in Excel or LibreOffice before trusting values read with data_only=True")
    return 1 if total_errors or total_uncached else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc