Name, Version and Archive Generated Excel Reports
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
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:
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.
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:
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:
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.
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:
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:
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.
Common pitfalls
- Hashing the whole file. Timestamps in
docPropschange every run; fingerprint the content parts. - Overwriting the published file. Write a new version; keep a
latestcopy 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.
Related
- Up one level: Publishing Excel Reports to Cloud Storage — where generated reports go.
- Write Excel Files to a Network Share from Python — the share the folder lives on.
- Write Excel Files Atomically in Python — publishing without half-written files.
- Skip a Report When the Input File Has Not Changed — avoiding the run altogether.
More on publishing & sharing files
- How-toRead an Excel File from SharePoint or OneDrive into pandas
- How-toShare an Excel File in OneDrive with a Link Using Graph
- How-toEmail a Download Link Instead of an Excel Attachment
- How-toSend an Excel Report with Amazon SES
- How-toGenerate an HTML Report from Excel Data with Jinja2
- How-toPost an Excel Report to a Slack Channel with Python