How-to
Automating Reporting WorkflowsDeep dive

Wait for Upstream Data Before Running a Report

Stop a scheduled Excel report from running on yesterday's data: before building, check that every input is ready — an export file dated today and finished, a marker file, a database load-status row — poll with growing intervals until a deadline, and then either run, or send a clear "data is late" notice instead of a wrong report. Tested with a simulated clock.

A report scheduled for 07:00 assumes that the data it needs is there by 07:00. Most days it is. On the day the ERP export runs late, or the overnight database load fails halfway, the report still runs on time — and quietly produces figures from yesterday's file or from a half-loaded table. Nothing errors, the email goes out, and the wrong numbers are discussed in the morning meeting. The cure is to make the report check that its inputs are ready before it builds anything, wait a while if they are not, and give up at a deadline with a clear message instead of a wrong report. This page builds those readiness checks for the three common kinds of input — a file, a marker, a database table — a polling loop with a deadline, and a test that runs the whole thing against a simulated clock. It belongs to Scheduling Python Excel Scripts with Cron.

Prerequisites

Bash
pip install pandas openpyxl

The examples use a local folder for the export file and a SQLite database with a load-status table, both created on the fly. The same checks work against a network share, an SFTP folder or a production database with the connection changed.

Step 1 — Write each readiness check as a small function

Python
import sqlite3
from contextlib import closing
from datetime import date, datetime, timedelta
from pathlib import Path

def export_file_ready(folder, business_date, min_age_s=120, now=None):
    """The export for the business date exists, is non-empty and has not changed for min_age_s."""
    now = now or datetime.now()
    path = Path(folder) / f"sales_{business_date:%Y%m%d}.xlsx"
    if not path.exists():
        return False, f"{path.name} not there yet"
    st = path.stat()
    if st.st_size == 0:
        return False, f"{path.name} is empty"
    age = now.timestamp() - st.st_mtime
    if age < min_age_s:
        return False, f"{path.name} changed {age:.0f}s ago, may still be copying"
    return True, f"{path.name} ready ({st.st_size:,} bytes)"

def marker_ready(folder, business_date):
    marker = Path(folder) / f"sales_{business_date:%Y%m%d}.done"
    return marker.exists(), f"{marker.name} {'found' if marker.exists() else 'missing'}"

def table_loaded(db, table, business_date):
    with closing(sqlite3.connect(db)) as con:
        row = con.execute("SELECT status, rows FROM load_status WHERE table_name = ? AND business_date = ?",
                          (table, business_date.isoformat())).fetchone()
    if row is None:
        return False, f"{table}: no load recorded for {business_date}"
    status, rows = row
    return (status == "complete" and rows > 0), f"{table}: {status}, {rows:,} rows"

Each check returns two things: whether the input is ready and a sentence saying why or why not. The sentence is what turns a late-data notice from "inputs not ready" into something a person can act on, such as "sales_20261005.xlsx changed 40s ago, may still be copying". The checks are specific to the business date the report is for, not to "the newest file" — picking up the newest file is exactly how a report ends up running on yesterday's data without anyone noticing.

The file check has a subtlety. A file that exists may still be arriving: a large export copied over a network share appears at its full name long before the last byte is written. Requiring the file to be unchanged for a couple of minutes is a cheap guard. A marker file is better when the upstream system can write one — the producer writes the data, then creates an empty .done file, and the marker's existence means the data is complete. For a database, the equivalent is a load-status table that the load process updates at the end, so a half-finished load is never mistaken for a finished one.

Three kinds of readiness check A file input is ready when the file for the business date exists, is not empty and has not changed for two minutes. A marker input is ready when the producer has written its .done file. A database input is ready when the load-status table records a complete load with rows for the business date. A B C 1 Input Ready when Guards against 2 Export file dated file exists, stable 2 min yesterday's file, half-copied file 3 Marker file .done written by the producer data still being written 4 Database table load_status complete, rows > 0 failed or partial load

Step 2 — Poll until ready or until the deadline

Python
import time

def wait_until_ready(checks, deadline, now=datetime.now, sleep=time.sleep, first_wait=60, max_wait=600):
    wait = first_wait
    while True:
        results = [(name, *check()) for name, check in checks]
        pending = [(n, why) for n, ok, why in results if not ok]
        if not pending:
            return True, results
        if now() >= deadline:
            return False, results
        pause = min(wait, max_wait, max((deadline - now()).total_seconds(), 0))
        print(f"{now():%H:%M:%S} waiting {pause:.0f}s for: " + "; ".join(why for _, why in pending))
        sleep(pause)
        wait *= 2

The loop runs every check, returns as soon as all of them pass, and otherwise sleeps and tries again. The waits grow — one minute, two, four, then capped at ten — so a late input is noticed quickly when it is only a little late, without checking every few seconds for an hour when it is very late. The last wait is shortened so the loop wakes exactly at the deadline. The clock and the sleep function are parameters, which costs nothing in production and makes it possible to test the whole loop in a fraction of a second, as step 4 does.

Step 3 — Run, or say clearly that the data is late

Python
def run_report(business_date, folder, db, deadline, now=datetime.now, sleep=time.sleep):
    checks = [("export", lambda: export_file_ready(folder, business_date, now=now())),
              ("marker", lambda: marker_ready(folder, business_date)),
              ("orders table", lambda: table_loaded(db, "orders", business_date))]
    ready, results = wait_until_ready(checks, deadline, now=now, sleep=sleep)
    if not ready:
        lines = [f"- {name}: {'ready' if ok else why}" for name, ok, why in results]
        return "LATE", (f"The {business_date:%d %b} sales report has not been produced: "
                        f"inputs were not ready by {deadline:%H:%M}.\n" + "\n".join(lines))
    # build_report(business_date) would go here
    return "OK", f"report for {business_date} built from: " + "; ".join(why for _, _, why in results)

When the deadline passes, the job does not fall back to the data it has. It sends a short notice instead — to the report's recipients if they would otherwise wonder where it is, or to the people who run the upstream system — listing each input with its state. A notice that says "the orders table load is still running" saves an hour of emails; a report built on partial data costs much more, because nobody knows it is wrong. Once the data arrives, the report can be produced by rerunning the job for that business date, which is safe if the job is written to be rerun, as described in Make an Excel Report Job Safe to Rerun.

What the job does at 07:00 The job runs all readiness checks. If everything is ready it builds the report. If not, it waits with growing intervals and checks again until the deadline, after which it sends a data-is-late notice listing each input's state instead of building a report. 07:00 start business date known Run all checks file, marker, table Not ready? wait 1, 2, 4… min Ready build and send Deadline passed late-data notice

Step 4 — Test it against a simulated clock

Python
import os, tempfile

class FakeClock:
    def __init__(self, start):
        self.t = start
    def now(self):
        return self.t
    def sleep(self, seconds):
        self.t += timedelta(seconds=seconds)
        for at, action in list(events):
            if at <= self.t:
                action(); events.remove((at, action))

folder = Path(tempfile.mkdtemp()); db = str(folder / "warehouse.sqlite")
with closing(sqlite3.connect(db)) as con, con:
    con.execute("CREATE TABLE load_status (table_name TEXT, business_date TEXT, status TEXT, rows INTEGER)")
bd = date(2026, 10, 5)

def drop_export(at):
    p = folder / f"sales_{bd:%Y%m%d}.xlsx"
    p.write_bytes(b"x" * 48_213)
    os.utime(p, (at.timestamp(), at.timestamp()))
def write_marker():
    (folder / f"sales_{bd:%Y%m%d}.done").touch()
def finish_load():
    with closing(sqlite3.connect(db)) as con, con:
        con.execute("INSERT INTO load_status VALUES ('orders', ?, 'complete', 18342)", (bd.isoformat(),))

start = datetime(2026, 10, 6, 7, 0)
clock = FakeClock(start)
events = [(start + timedelta(minutes=5), lambda: drop_export(start + timedelta(minutes=5))),
          (start + timedelta(minutes=6), write_marker), (start + timedelta(minutes=13), finish_load)]
print(run_report(bd, folder, db, deadline=start + timedelta(minutes=45), now=clock.now, sleep=clock.sleep))

The fake clock replaces datetime.now and time.sleep: sleeping simply moves the clock forward and fires any events that are due, such as the export file landing at 07:05 or the database load finishing at 07:13. The test runs in milliseconds but walks through the real sequence of waits. It is worth also running it with an event that never happens — the database load never finishing — to see the late-data notice, which is what the next block does.

Python
for f in folder.iterdir():
    if f.suffix in (".xlsx", ".done"):
        f.unlink()
with closing(sqlite3.connect(db)) as con, con:
    con.execute("DELETE FROM load_status")
clock = FakeClock(start)
events = [(start + timedelta(minutes=5), lambda: drop_export(start + timedelta(minutes=5))),
          (start + timedelta(minutes=6), write_marker)]
status, message = run_report(bd, folder, db, deadline=start + timedelta(minutes=45), now=clock.now, sleep=clock.sleep)
print(status); print(message)

In the first run the checks at 07:00, 07:01 and 07:03 find nothing; at 07:07 the export and marker are ready and only the database load is outstanding; at 07:15 everything passes and the report is built. In the second run the load never finishes, the waits grow to the ten-minute cap, and at 07:45 the job returns the late notice with the export and marker marked ready and the orders table named as the problem.

The simulated morning Checks at 07:00, 07:01 and 07:03 find no inputs. The export lands at 07:05 and the marker at 07:06, so the 07:07 check waits only for the database. The load completes at 07:13 and the 07:15 check builds the report. A B C D E 1 Check at Export file Marker Orders table Result 2 07:00 missing missing no load wait 1 min 3 07:01 missing missing no load wait 2 min 4 07:03 missing missing no load wait 4 min 5 07:07 ready ready no load wait 8 min 6 07:15 ready ready complete, 18,342 rows build report

Choosing the deadline

The deadline is a business decision more than a technical one. Work back from when the report is needed: if the morning meeting is at 09:00 and the report takes ten minutes to build and send, a deadline of 08:30 leaves a margin. Start the job early enough that a normal day finds the data ready on the first check, and use the history of when inputs actually arrived — the waiting messages are a good record if they are logged — to set the start time and spot an upstream process that is drifting later week by week.

Common pitfalls

  • Using the newest file. Look for the file for the business date, so yesterday's never passes.
  • Treating "exists" as "complete". Check stability, a marker file or a load-status row.
  • Falling back to old data at the deadline. Send a late-data notice instead.
  • Fixed one-minute polling for hours. Grow the interval and cap it.
  • Testing with real waits. Inject the clock and the sleep and simulate the morning.

Conclusion

Before a scheduled report builds anything, check that each input is ready for the business date — a stable export file, a producer's marker file, a complete load-status row — and poll with growing waits until a deadline. If the deadline passes, send a notice that lists each input's state instead of a report built on stale or partial data, and rerun the report once the data arrives. Inject the clock so the waiting logic can be tested in milliseconds.

Frequently asked questions

How do I make a Python script wait for a file before running? Check for the expected file name for the business date in a loop, sleep between checks with a growing interval, and stop at a deadline.

How can I tell that a file has finished copying? Wait until its size and modification time have not changed for a short period, or better, have the producer write a separate marker file when it is done.

What should a report do if its data is late? Not run on old data; send a short notice explaining which inputs are missing, and rerun once they arrive.

How do I test code that sleeps and reads the clock? Pass the clock and sleep functions in as parameters and replace them in tests with a fake clock that advances when asked to sleep.

More on scheduling & orchestration

More on scheduling & orchestration →