Guide
Advanced Data Transformation And CleaningDeep dive

Refresh an Excel Report from a Database on a Schedule

Keep a workbook current without anyone asking: an incremental query with a watermark, an atomic write nobody can catch half-finished, a freshness stamp readers can see, and a check that the refresh actually ran.

An extract is only useful while it is current, and the failure mode of a scheduled refresh is not a crash — it is silence. The job stops being triggered, or fails at 06:00 into a log nobody reads, and the workbook on the shared drive keeps being opened and quoted for another three weeks. Nobody notices, because a stale file looks exactly like a fresh one.

This guide covers the four things that make a scheduled refresh trustworthy: querying incrementally so the run stays inside its window, writing the file atomically so nobody can open it half-written, stamping it so readers can see how old it is, and checking its age so a refresh that stops running gets noticed. It is part of Moving Data Between Excel and Databases.

One refresh cycle, from watermark to published file The job reads the stored watermark, queries only rows changed since then, merges them into the cached extract, writes a temporary workbook, and replaces the published file atomically. It then records the new watermark and the generation time, and a separate monitor alerts when the published file stops getting younger. read watermark last updated_at query changes WHERE > :watermark merge + build into report.tmp.xlsx os.replace atomic swap published readers see it write the new watermark only after the swap succeeds A monitor watches the published file's age — a job that stops running raises nothing else

Prerequisites

Bash
pip install pandas openpyxl sqlalchemy

You also need the export itself working as a one-off first; Export SQL Query Results to Excel with Python covers the query, the sheets and the formatting this guide schedules.

Step 1: Query only what changed

A full rebuild is the right default while it is fast enough — it holds no state, so it cannot drift. When the full pull stops fitting the window, switch to a watermark: the highest updated_at the last successful run saw.

Python
import json
from pathlib import Path

import pandas as pd
from sqlalchemy import create_engine, text

engine = create_engine(os.environ["REPORT_DB_URL"], pool_pre_ping=True)
STATE = Path("state/refresh.json")

CHANGED = text("""
    SELECT order_id, region, order_date, amount, updated_at
    FROM orders
    WHERE updated_at > :since
    ORDER BY updated_at
""")


def read_state():
    if STATE.is_file():
        return json.loads(STATE.read_text())
    return {"watermark": "1970-01-01T00:00:00", "rows": 0}


def fetch_changes(since):
    with engine.connect() as conn:
        return pd.read_sql(CHANGED, conn, params={"since": since},
                           parse_dates=["order_date", "updated_at"])

Two rules keep a watermark honest. Use a strictly greater-than comparison against the maximum value you actually received, not now() — clock differences between the database and the job are exactly how rows go missing. And make sure the source column is updated on every write, including deletes-as-flags; a watermark over a column that some updates skip silently drops those rows forever.

Because rows can be updated as well as inserted, the merge has to be a replace rather than an append:

Python
def merge(cached, changes, key="order_id"):
    if cached is None or cached.empty:
        return changes
    keep = cached[~cached[key].isin(changes[key])]
    return (pd.concat([keep, changes], ignore_index=True)
              .sort_values(["region", "order_date"])
              .reset_index(drop=True))

Step 2: Write the file atomically

A reader who opens the workbook while the job is halfway through writing it gets a corrupt file or a partial one. Write to a temporary name in the same directory and swap:

Python
import os
from pathlib import Path


def publish(df, target, build):
    """build(df, path) writes the workbook; this makes the swap atomic."""
    target = Path(target)
    tmp = target.with_name(f".{target.stem}.tmp{target.suffix}")
    build(df, tmp)
    os.replace(tmp, target)          # atomic within one filesystem
    return target
What a reader sees during an in-place write and during an atomic swap Writing directly to the published path leaves a window in which the file exists but is incomplete, so a reader who opens it during the refresh gets a truncated or corrupt workbook. Writing to a temporary name and replacing means the published path always points at a complete file — the previous one, then the new one. written in place complete partial file complete a reader opening here gets a broken workbook the window is short and entirely predictable — it is 06:00, when everybody opens the report temp file, then os.replace yesterday's file today's file swap there is no moment in between the temporary file must be in the same directory, or the rename becomes a copy and loses the guarantee

os.replace is atomic on POSIX and on Windows: readers see either the old file or the new one, never a partial write. The temporary file must be in the same directory as the target — a rename across filesystems is a copy, and a copy is not atomic. Prefixing the temporary name with a dot keeps it out of the way of anyone browsing the folder mid-run.

On Windows there is one extra case: if a colleague has the workbook open in Excel, the replacement raises PermissionError because Excel holds a lock on the target. Decide deliberately which behaviour you want:

Python
def publish_or_park(df, target, build, retries=3, wait=20):
    for attempt in range(1, retries + 1):
        try:
            return publish(df, target, build)
        except PermissionError:
            if attempt == retries:
                parked = Path(target).with_name(
                    f"{Path(target).stem}-{pd.Timestamp.now():%Y%m%d-%H%M}.xlsx")
                build(df, parked)
                raise RuntimeError(f"{target} is locked; wrote {parked} instead")
            time.sleep(wait)

Parking the output under a dated name means a locked file delays publication rather than losing the run's work — and the error names both files, so whoever is holding the lock knows what to do. Handle Permission Denied When Writing Excel in Python covers the lock behaviour in more detail.

Step 3: Stamp the workbook so readers can see its age

The cheapest reliability feature in reporting is a visible timestamp:

Python
from openpyxl.styles import Font


def build_workbook(df, path):
    summary = df.groupby("region", as_index=False)["amount"].sum()
    with pd.ExcelWriter(path, engine="openpyxl",
                        datetime_format="yyyy-mm-dd") as writer:
        summary.to_excel(writer, sheet_name="Summary", index=False, startrow=2)
        df.to_excel(writer, sheet_name="Detail", index=False)

        ws = writer.sheets["Summary"]
        ws["A1"] = f"Generated {pd.Timestamp.now():%Y-%m-%d %H:%M} · {len(df):,} rows"
        ws["A1"].font = Font(italic=True, color="5B6780")
        ws.freeze_panes = "A4"

startrow=2 leaves room for the stamp above the table. Put it on the first sheet, above the fold, not in a footer or a hidden metadata sheet: the point is that a reader who has had the file open for a fortnight sees the date without looking for it.

Step 4: Record state only after success

The watermark must advance only when the file has actually been published — otherwise a failure between the query and the write skips those rows on the next run:

Python
def refresh(target="reports/orders.xlsx"):
    state = read_state()
    changes = fetch_changes(state["watermark"])

    if changes.empty:
        log.info("no changes since %s", state["watermark"])
        return 0

    cached = load_cached_extract()
    merged = merge(cached, changes)
    publish(merged, target, build_workbook)
    save_cached_extract(merged)

    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps({
        "watermark": changes["updated_at"].max().isoformat(),
        "rows": len(merged),
        "published_at": pd.Timestamp.now().isoformat(timespec="seconds"),
    }, indent=2))
    return len(changes)

The ordering is the whole point: query, merge, publish, then record. Written the other way round — state first — a crash during the write advances the watermark past rows that were never published, and those rows are gone from every future run. The same argument applies to the cached extract, which is why it is saved after the publish rather than before.

Why the watermark is written last If the watermark is saved before the file is published, a crash during the write leaves the state advanced past rows that never reached the workbook, and no future run will fetch them again. Saving it after the publish means a crash simply repeats the same query on the next run. watermark first save state build crash ✗ rows are past the watermark but not in the file no future run will ever fetch them — the gap is permanent and invisible watermark last build crash ✗ save state the state still points at the last good run the next run repeats the same query and publishes what the crashed one could not Repeating work after a crash is cheap; losing rows after one is not

Step 5: Alert on staleness, not just on failure

A failing job raises an error someone can route. A job that stops being triggered — a disabled task, a deleted crontab, a decommissioned server — raises nothing at all. The only signal is the file's age, so check that separately from the job itself:

Python
from datetime import datetime, timedelta


def check_freshness(path, max_age=timedelta(hours=26)):
    path = Path(path)
    if not path.exists():
        return f"{path} does not exist — the refresh has never succeeded"
    age = datetime.now() - datetime.fromtimestamp(path.stat().st_mtime)
    if age > max_age:
        return f"{path} is {age.total_seconds() / 3600:.1f}h old (limit {max_age})"
    return None

Run that from a different schedule than the refresh — a separate cron entry, a monitoring system, anything that is not the job being watched. The allowance of 26 hours for a daily job is deliberate slack: it tolerates a late run without alerting, and still fires long before anyone would quote day-old numbers as current. Wire the message into whatever the rest of your error handling and logging already uses.

Common pitfalls and gotchas

SymptomCauseFix
Rows missing from the extractWatermark set from now() rather than the dataUse max(updated_at) of the rows received
Duplicate rows after a refreshChanges appended instead of replaced by keyDrop matching keys from the cache, then concat
Corrupt file for a readerThe workbook was written in placeWrite to a temp name, then os.replace
PermissionError on WindowsExcel holds a lock on the open fileRetry, then park under a dated name
Numbers quoted weeks later as currentNo visible timestampStamp the summary sheet above the table
Silent stop, nobody noticedOnly the job's exit code was monitoredAlert on the output file's age
Refresh grows slower each weekFull rebuild over a growing tableMove to the watermark query
Timezone drift in the watermarkNaive local times on both sidesStore UTC, compare UTC

Performance and scale notes

Incremental refresh trades a simple job for a stateful one, so make the switch only when the numbers justify it. A full extract that takes 40 seconds is not worth complicating; one taking twenty minutes and growing is. When you do switch, keep a periodic full rebuild — weekly is common — so that any rows a bad watermark missed get corrected rather than accumulating.

Two indexes decide the cost: one on the watermark column for the incremental query, and one on the key used by the merge. Without the first, every refresh scans the whole table and the incremental version is slower than the full one it replaced.

Conclusion

A scheduled refresh is a small amount of code and four decisions. Query with a watermark taken from the data, not the clock. Publish atomically so a reader never opens a half-written file, and decide in advance what a locked file should do. Stamp the workbook where readers will see it. Record the new state only after the publish succeeds, and monitor the file's age separately, because the failure that actually happens is the job quietly not running at all.

Frequently asked questions

What happens if someone has the file open when the refresh runs? On Windows the replacement fails with a permission error, because Excel holds a lock. Write to a temporary name and replace, catch the error, and either retry or publish under a dated filename so a lock never blocks the whole run.

Full refresh or incremental? Full while it is fast enough — it has no state to get wrong. Move to an incremental watermark query when the full pull stops fitting in the window, and keep a periodic full rebuild to correct any drift.

How do readers know the numbers are current? Put a generated-at timestamp and the source period in a visible cell on the first sheet. A file without one is treated as current forever.

How do I get alerted when the refresh silently stops? Monitor the output file's age rather than the job's exit code. A job that never runs produces no failure — only a file that quietly gets older.

Up to the parent guide:

Related guides: