Skip to content
datahacks.dev
Excel

Putting a README Sheet Into Every Generated Workbook

Add a README sheet to every generated workbook with the period, source, definitions, checks and version, written by the same code that builds the report.

7 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar.

Open a workbook that arrived by email in March. It has a tab called Sheet1, a column called Amt, and no mention of which month it covers. Is it final? Which system produced it? Does Amt include tax? You ask the sender, who is on leave. Multiply that by every file in a shared folder and you have the real cost of undocumented spreadsheets. A README sheet is the cheapest fix: one tab, written by code, that carries the answers. This article lists what belongs on it, shows a generator that writes it, and covers the places where it falls short. It is one of the habits in building Excel reports from SQL queries people trust.

What readers need to know

A reader opens the file with five questions, in this order.

  1. What is this? The report's name and purpose in a sentence.

  2. What period and as of when? The reporting period, and the time the data was extracted.

  3. Where did it come from? The source system and the query, with a version.

  4. What do the columns mean? Especially the ones with a business definition.

  5. Can I trust it, and whom do I ask? The checks that passed and a contact.

A README that answers these in a screenful is enough. More than that and it becomes a document nobody opens.

A layout that stays readable

Use two columns, labels on the left and values on the right, one fact per row. No merged cells, no text boxes, no color coding that carries meaning. Plain cells are easy to read in Excel, in a preview pane and in a script.

FieldExample value
ReportMonthly sales by region
Period2026-09-01 to 2026-09-30 (inclusive)
Generated2026-10-02 06:04 UTC
SourceReporting database, tables orders, order_items, customers
Querysql/monthly_sales.sql at commit 9f2c1a7
RowsSales: 148
Time zoneDates are in Australia/Sydney
CurrencyUSD, whole units, rounded half up
Checks6 of 6 passed
Contact[email protected]

Below the table, add a short definitions block: one row per column that a stranger could misread, with the column name and a sentence.

ColumnMeaning
net_revenueInvoiced amount less credit notes, excluding tax
ordersDistinct orders placed in the period, test accounts excluded
refund_rateRefunds divided by gross revenue, as a share

The same table in the workbook is made of cells. The table above is for the reader of this article.

Generate it, never type it

If a person types the period into the README, the README will be wrong in the month they forget. Build every value from the same variables that built the report.

Python
import subprocess
from datetime import datetime, timezone

def git_commit():
    out = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True)
    return out.stdout.strip() or "unknown"

def readme_rows(period_start, period_end, sheets, checks):
    return [
        ("Report", "Monthly sales by region"),
        ("Period", f"{period_start} to {period_end} (inclusive)"),
        ("Generated", datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")),
        ("Source", "Reporting database: orders, order_items, customers"),
        ("Query", f"sql/monthly_sales.sql at commit {git_commit()}"),
        *[(f"Rows: {name}", len(df)) for name, df in sheets.items()],
        ("Checks", f"{sum(ok for _, ok in checks)} of {len(checks)} passed"),
        ("Contact", "[email protected]"),
    ]

The period comes from the same parameters passed to the query. The row counts come from the data frames just written. The check summary comes from the checks that ran. The commit hash comes from the repository the job runs in; if the job runs from a build artifact without a .git directory, pass the hash in as an environment variable at build time instead.

Write the rows with the other sheets:

Python
import pandas as pd

with pd.ExcelWriter("sales_2026-09.xlsx", engine="xlsxwriter") as xw:
    for name, df in sheets.items():
        df.to_excel(xw, sheet_name=name, index=False)
    readme = pd.DataFrame(readme_rows("2026-09-01", "2026-09-30", sheets, checks), columns=["Field", "Value"])
    readme.to_excel(xw, sheet_name="README", index=False)
    ws = xw.sheets["README"]
    ws.set_column("A:A", 24)
    ws.set_column("B:B", 70)

Row counts arrive as integers, so they stay numeric; everything else is text. Writing user-controlled strings into a README is rare, but the same formula injection rule applies if you echo anything from the data (formula injection in Excel exports).

First tab or last?

Both work, and consistency matters more. A README first gets read; a README last stays out of the way of people who open the file every month and want the data. A compromise is to make the README the first tab for the first three months of a new report, and move it last when the audience has learned the layout. Whatever you choose, name it README exactly, so scripts can find it, and keep the data sheets in a fixed order.

Definitions that carry weight

The definitions block earns its place with the columns that cause arguments. Include a definition when a term has more than one reasonable meaning in your organization: revenue, active customer, churn, headcount, margin. Write the one used here, in a sentence, with the inclusions and exclusions that matter.

Keep definitions in one place in the repository, a small file of column names and descriptions, and have the generator pull the ones that apply to the sheets it wrote. Then the definition of net_revenue is the same in every report, and fixing it fixes every workbook on the next build. A definition that lives in forty workbooks is forty chances to be wrong.

Record provenance so you can trace a number

Two items on the README make audit easy: the commit of the query and the time of extraction. With the commit, you can check out the exact SQL that produced the workbook and rerun it. With the extraction time, you can see whether a number differing from today's is due to late data. For data that changes after the fact, add the load or snapshot identifier if you have one.

If the report is signed off by a person before distribution, record that too: reviewer and time. It turns the README into a small audit record (producing a monthly management pack from SQL shows the sign off process).

Keep it machine-readable

Someone will write a script that reads your workbook. Make that easy. Keep the README as two columns with a header, put each fact in its own row and use fixed labels. A loader can then read the sheet into a dictionary and check the period:

Python
import pandas as pd

meta = dict(pd.read_excel("sales_2026-09.xlsx", sheet_name="README").values[:10])
assert meta["Period"].startswith("2026-09-01")

That check is useful in a downstream job that should refuse to load the wrong month. It is also why the README should not use merged cells or free-form layout.

What a README should not do

Do not put secrets on it: no connection strings, no passwords, no internal hostnames that you would not show an outsider. Do not paste the full SQL, which is long and gets stale; reference the file and the commit. Do not use it for marketing or a disclaimer essay. And do not use it to hide the sensitive details of the data: if the workbook goes outside the company, review the README as carefully as the data tabs (hidden sheets and metadata leaking out of shared workbooks).

Test that it exists

Add a check to your report tests: the workbook has a sheet named README, its Period matches the parameters, and the row counts listed equal the rows in the sheets. That keeps the README from rotting when someone adds a sheet to the report and forgets the metadata (testing a generated Excel report). The XlsxWriter documentation on worksheets lists the methods for column widths and protection that you may want on the README, and the openpyxl documentation covers reading it back.

When a README sheet is not enough

A README does not replace a data dictionary for a database with hundreds of tables; link to the dictionary instead. It does not replace a changelog for a report that evolves: the repository history holds that, and a "What changed" line on the README can point to it. It does not give you access control or retention. And it does not help if the workbook is edited after generation, because the README then describes a file that no longer exists. For reports that people edit, either protect the data sheets or accept that the README documents the original build and say so on it.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.