How-to
Automating Reporting WorkflowsDeep dive

Name, Version and Archive Generated Excel Reports

Stop overwriting report_final_v2.xlsx: a sortable naming scheme for generated Excel reports, a content fingerprint that ignores the timestamps Excel files carry so unchanged reruns are skipped, numbered versions with a manifest, a retention rule that archives old periods and keeps final versions, and the same ideas on Amazon S3 with object versioning.

A report job that runs every day for a year leaves a trail: dozens of files called sales_report.xlsx, sales_report (1).xlsx and sales_report_final_v2.xlsx, or a single file overwritten every morning with no way back to what was sent last Tuesday. Both are problems the first time someone asks "which numbers did the board see?". A little structure fixes it: a naming scheme that sorts correctly and says what each file contains, a version number that only increases when the content actually changes, a manifest recording every version, and a retention rule that archives old periods without deleting the versions that matter. This page builds those pieces for a folder or network share, then maps them onto Amazon S3. It belongs to Publishing Excel Reports to Cloud Storage.

Prerequisites

Bash
pip install pandas openpyxl boto3 moto

The examples write reports into a local reports/ folder; the same code works on a mounted network share. The S3 section uses boto3, with moto standing in for AWS so it can run without an account.

Step 1 — A naming scheme that sorts and parses

A good file name answers three questions — which report, for which period, which version — and sorts in a file browser in the order people expect. ISO dates and zero-padded numbers do that:

Python
import re
from pathlib import Path

NAME = re.compile(r"^(?P<report>[a-z0-9-]+)_(?P<period>\d{4}-\d{2}(?:-\d{2})?)_v(?P<version>\d{3})\.xlsx$")

def report_name(report, period, version):
    return f"{report}_{period}_v{version:03d}.xlsx"

def parse_name(name):
    m = NAME.match(name)
    return None if m is None else (m["report"], m["period"], int(m["version"]))

names = [report_name("sales-report", p, v) for p, v in [("2026-09", 2), ("2026-10", 1), ("2026-09", 10), ("2026-09", 1)]]
print(sorted(names))
print(parse_name("sales-report_2026-09_v010.xlsx"), parse_name("Sales Report final.xlsx"))

The report part is lowercase with hyphens, the period is YYYY-MM (or YYYY-MM-DD for daily reports), and the version is three digits. Sorted alphabetically, the files fall into period order and then version order — v002 before v010, which would not happen with v2 and v10. Underscores separate the parts and appear nowhere else, so the name parses back unambiguously, and anything that does not match — a hand-saved copy, say — is easy to detect. Avoid spaces and characters such as : that are illegal on Windows shares or need escaping in URLs.

The parts of a report file name A file name such as sales-report underscore 2026-09 underscore v003 dot xlsx holds the report name in lowercase with hyphens, the ISO period, and a zero-padded version number, separated by underscores so it sorts and parses reliably. sales-report report, lowercase-hyphen 2026-09 ISO period, sorts by date v003 zero-padded version .xlsx no spaces or colons

Step 2 — Fingerprint the content, not the file

To decide whether a rerun needs a new version, compare content. Hashing the file does not work, because every .xlsx carries its creation and modification time in docProps/core.xml, so two runs with identical data produce different bytes:

Python
import hashlib
import time
import zipfile
import pandas as pd

def write_report(path, df):
    df.to_excel(path, index=False, sheet_name="Sales")

def file_hash(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12]

def content_fingerprint(path):
    """Hash every part of the workbook except the document properties."""
    h = hashlib.sha256()
    with zipfile.ZipFile(path) as z:
        for name in sorted(z.namelist()):
            if not name.startswith("docProps/"):
                h.update(name.encode())
                h.update(z.read(name))
    return h.hexdigest()[:12]

data = pd.DataFrame({"Region": ["North", "South"], "Revenue": [18400, 12100]})
write_report("run1.xlsx", data)
time.sleep(1.1)
write_report("run2.xlsx", data)
print("file hash:  ", file_hash("run1.xlsx"), file_hash("run2.xlsx"))
print("fingerprint:", content_fingerprint("run1.xlsx"), content_fingerprint("run2.xlsx"))

The file hashes differ even though nothing in the report changed; the fingerprints match. The fingerprint covers the sheets, styles, shared strings and everything else in the package except docProps/, so a changed number, a new row or a formatting change all produce a new fingerprint. An alternative is to make the output byte-for-byte reproducible by fixing the timestamps, as in Make Excel Output Reproducible; the fingerprint approach needs no change to how the workbook is written.

Step 3 — Publish a new version only when content changes

The publishing function finds the latest version for the period, compares fingerprints, and either skips or writes the next version — never overwriting an existing file — and records it in a manifest:

Python
import csv
import shutil
from datetime import datetime, timezone

ROOT = Path("reports")
MANIFEST = ROOT / "manifest.csv"

def versions(report, period):
    found = [parse_name(p.name) for p in ROOT.glob(f"{report}_{period}_v*.xlsx")]
    return sorted(v for (_, _, v) in filter(None, found))

def publish(tmp_file, report, period):
    ROOT.mkdir(exist_ok=True)
    existing = versions(report, period)
    fp = content_fingerprint(tmp_file)
    if existing:
        latest = ROOT / report_name(report, period, existing[-1])
        if content_fingerprint(latest) == fp:
            return latest, "unchanged"
    target = ROOT / report_name(report, period, (existing[-1] + 1) if existing else 1)
    shutil.copy2(tmp_file, target)
    new = not MANIFEST.exists()
    with MANIFEST.open("a", newline="") as fh:
        w = csv.writer(fh)
        if new:
            w.writerow(["file", "report", "period", "version", "fingerprint", "published_utc"])
        w.writerow([target.name, report, period, target.stem.rsplit("_v", 1)[1], fp,
                    datetime.now(timezone.utc).isoformat(timespec="seconds")])
    return target, "published"

print(publish("run1.xlsx", "sales-report", "2026-09"))
print(publish("run2.xlsx", "sales-report", "2026-09"))           # same data, rerun
data.loc[1, "Revenue"] = 12350                                    # a late correction
write_report("run3.xlsx", data)
print(publish("run3.xlsx", "sales-report", "2026-09"))
print(MANIFEST.read_text().splitlines()[-1].split(",")[:5])

The first run publishes v001. The rerun with identical data is recognised as unchanged and writes nothing, so a job that runs hourly does not produce 24 identical versions a day. The late correction to South's revenue produces v002. Every published file appears in the manifest with its fingerprint and UTC time, which answers "which version existed when?" without relying on file modification times that copies and backups change. If people need a stable link, also copy the newest file to a fixed name such as sales-report_2026-09_latest.xlsx — but keep the numbered files as the record.

What happens when the job produces a report The new file's content fingerprint is compared with the latest published version for the same period. If there is no version yet, it becomes v001. If the fingerprint matches, nothing is written. If it differs, the next version number is written and recorded in the manifest. Compare with the latest version no version yet publish v001 add to manifest same fingerprint skip nothing written content changed publish next version never overwrite

Step 4 — Archive old periods and keep what matters

Without a retention rule, the folder grows forever. A typical rule keeps every version of recent periods (corrections still happen), and for older periods keeps only the final version, moved into an archive folder by year. Compute the plan first and apply it separately, so the rule can be reviewed in a dry run:

Python
def retention_plan(report, keep_recent=3):
    by_period = {}
    for p in ROOT.glob(f"{report}_*_v*.xlsx"):
        parsed = parse_name(p.name)
        if parsed:
            by_period.setdefault(parsed[1], []).append((parsed[2], p))
    periods = sorted(by_period)
    plan = []
    for period in periods[:-keep_recent]:
        files = sorted(by_period[period])
        final = files[-1][1]
        plan.append(("archive", final, ROOT / "archive" / period[:4] / final.name))
        plan += [("delete", p, None) for _, p in files[:-1]]
    return plan

for period, n in [("2026-05", 2), ("2026-06", 1), ("2026-07", 3), ("2026-08", 1)]:
    for v in range(1, n + 1):
        shutil.copy2("run1.xlsx", ROOT / report_name("sales-report", period, v))

for action, src, dst in retention_plan("sales-report"):
    print(f"{action:8} {src.name}" + (f" → {dst.relative_to(ROOT)}" if dst else ""))

With five periods on disk and the three most recent kept intact, May and June are archived: May's final v002 moves to archive/2026/ and its superseded v001 is listed for deletion; June has only one version, which is archived. July, August and September keep every version. Applying the plan is a loop of mkdir(parents=True), shutil.move and unlink — run it only after the dry-run output looks right, and log what it did. Many organisations have a legal retention period for financial reports; set keep_recent and the delete rule to match it, or skip deletion entirely and only archive.

The same on Amazon S3

On S3, the naming scheme becomes the object key — a prefix per report and year, such as reports/sales-report/2026/sales-report_2026-09_v002.xlsx, lets you list one report's history cheaply. S3 can also keep versions for you: with bucket versioning turned on, every upload to the same key keeps the previous object as a non-current version:

Python
import boto3
from moto import mock_aws

with mock_aws():
    s3 = boto3.client("s3", region_name="eu-west-1")
    s3.create_bucket(Bucket="acme-reports", CreateBucketConfiguration={"LocationConstraint": "eu-west-1"})
    s3.put_bucket_versioning(Bucket="acme-reports", VersioningConfiguration={"Status": "Enabled"})
    key = "reports/sales-report/2026/sales-report_2026-09.xlsx"
    for f in ("run1.xlsx", "run3.xlsx"):
        s3.upload_file(f, "acme-reports", key, ExtraArgs={"Metadata": {"fingerprint": content_fingerprint(f)}})
    vs = s3.list_object_versions(Bucket="acme-reports", Prefix=key)["Versions"]
    print([(v["IsLatest"], s3.head_object(Bucket="acme-reports", Key=key, VersionId=v["VersionId"])["Metadata"]["fingerprint"]) for v in vs])

Both uploads are kept, the newest marked as latest, and each carries its content fingerprint as object metadata so a later run can compare before uploading. Bucket versioning protects against accidental overwrites and deletes; explicit version numbers in the key remain clearer for people browsing the bucket. Lifecycle rules then handle retention without code — for example, moving non-current versions to a cheaper storage class after 90 days. Uploading itself is covered in Upload an Excel Report to Amazon S3 with boto3.

Versioning on a file share and on S3 On a file share, versions are explicit numbers in the file name, recorded in a manifest, with retention applied by a script. On S3 with bucket versioning, every overwrite keeps the previous object, metadata can hold the fingerprint, and lifecycle rules apply retention. folder or network share version number in the name manifest.csv records each one retention script, dry run first S3 with versioning every overwrite kept fingerprint in object metadata lifecycle rules for retention

Common pitfalls

  • Hashing the whole file. Timestamps in docProps change every run; fingerprint the content parts.
  • Overwriting the published file. Write a new version; keep a latest copy only as a convenience.
  • Unsortable names. Use ISO dates and zero-padded versions.
  • Deleting without a dry run. Compute the retention plan, review it, then apply it.
  • Retention shorter than the legal requirement. Check how long financial reports must be kept.

Conclusion

Give generated reports names of the form report_YYYY-MM_v001.xlsx so they sort and parse, decide whether a rerun is a new version by fingerprinting the workbook's content parts rather than the file, write each new version alongside the old ones and record it in a manifest, and apply a reviewed retention plan that archives the final version of older periods. On S3, use the same names as keys and let bucket versioning and lifecycle rules add a second layer of protection.

Frequently asked questions

How should I name generated Excel report files? Use the report name in lowercase with hyphens, an ISO period such as 2026-09, and a zero-padded version, separated by underscores, for example sales-report_2026-09_v003.xlsx. Such names sort correctly and parse back reliably.

Why does the hash of my Excel file change when the data has not? Every xlsx stores its creation and modification times in docProps/core.xml. Hash the other parts of the zip package instead to compare content.

How do I avoid creating a new version on every rerun? Compare the content fingerprint of the new file with the latest published version and write a new version only when they differ.

How do I archive old Excel reports with Python? Group files by period, keep every version of recent periods, move the final version of older periods into an archive folder, and review the plan in a dry run before deleting superseded versions.

More on publishing & sharing files

More on publishing & sharing files →