How-to
Automating Reporting WorkflowsDeep dive

Send Report Emails at Each Recipient's Local Time

Deliver a report email at 08:00 in each recipient's own time zone from one scheduled Python job: keep an IANA time zone per recipient in the distribution list, run the job hourly, decide who is due with zoneinfo, skip local weekends and holidays, record sends so nobody gets the report twice, and check the logic across daylight saving changes.

A report that lands at 08:00 London time arrives at 03:00 in New York and 16:00 in Singapore. For a team spread across time zones, a single send time is wrong for most of them: the report is buried under a night's email for some and arrives after the working day for others. The fix is not one cron entry per time zone — that multiplies as the list grows and breaks twice a year when the clocks change — but a single job that runs every hour and, each time, sends to exactly the recipients for whom it is now the start of their working day. This page builds that job: a distribution list with a time zone for each person, the "is it 08:00 for them now?" check with Python's zoneinfo, local weekends and holidays, a send log so a rerun never duplicates, and a test that walks the clock through the autumn change to prove nobody gets the report twice or not at all. It belongs to Emailing Excel Reports with smtplib.

Prerequisites

Bash
pip install pandas openpyxl holidays

zoneinfo is in the standard library from Python 3.9; on Windows it also needs pip install tzdata, because Windows does not ship the time zone database. The distribution list is a small Excel sheet with each recipient's name, email, IANA time zone (Europe/London, America/New_York), country for public holidays and preferred local send hour.

Step 1 — Validate the time zones in the list

Python
import pandas as pd
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

people = pd.read_excel("distribution.xlsx")
bad = []
for tz in people["Time zone"].unique():
    try:
        ZoneInfo(tz)
    except (ZoneInfoNotFoundError, ValueError):
        bad.append(tz)
assert not bad, f"unknown time zones: {bad}"
print(people.to_string(index=False))

The time zone column must hold IANA names, not abbreviations or offsets. "EST" or "+01:00" look tidy but are wrong for half the year, because they do not know about daylight saving; America/New_York carries the full history of the city's clock changes and switches between UTC−5 and UTC−4 on the right dates by itself. Checking every name before the job does anything means a typo such as Europe/Londn stops the run with a clear message instead of silently skipping one person forever.

What to store in the time zone column Store IANA names such as Europe/London and America/New_York, which follow daylight saving automatically. Do not store abbreviations like EST or BST, or fixed offsets like plus one hour, which are correct for only part of the year. Store Europe/London America/New_York Asia/Singapore Australia/Sydney Avoid GMT or BST EST or EDT UTC+01:00 a city name typed freely

Step 2 — Decide who is due at a given moment

Python
from datetime import datetime, timezone
import holidays

WEEKENDS = {"SA": {4, 5}}       # Friday and Saturday; everywhere else Saturday and Sunday
_calendars = {}
def is_holiday(country, day):
    if country not in _calendars:
        _calendars[country] = holidays.country_holidays(country, years=range(2026, 2028))
    return day in _calendars[country]

def due(person, now_utc):
    """True when it is the person's send hour on one of their local working days."""
    local = now_utc.astimezone(ZoneInfo(person["Time zone"]))
    if local.hour != person["Send hour"]:
        return False
    weekend = WEEKENDS.get(person["Country"], {5, 6})        # Monday is 0
    return local.weekday() not in weekend and not is_holiday(person["Country"], local.date())

now = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)
print(f"{now:%a %d %b %H:%M} UTC ->", [p["Name"] for _, p in people.iterrows() if due(p, now)])

The decision is made entirely in the recipient's local time. The job takes the current moment in UTC, converts it to each person's zone, and asks two questions: is it their send hour, and is today a working day for them? Because the conversion uses the zone's rules for that exact instant, daylight saving is handled without any special code. Weekends are local too — the working week differs by country — and public holidays come from the holidays package by country, so a report due on a bank holiday in one country still goes out on time everywhere else.

Step 3 — Run hourly and log every send

Python
import json
from pathlib import Path

LOG = Path("sent_log.jsonl")

def already_sent(email, local_date):
    if not LOG.exists():
        return False
    return any(json.loads(line)["email"] == email and json.loads(line)["local_date"] == local_date
               for line in LOG.read_text().splitlines())

def run(now_utc, send):
    sent = []
    for _, p in people.iterrows():
        local_date = now_utc.astimezone(ZoneInfo(p["Time zone"])).date().isoformat()
        if due(p, now_utc) and not already_sent(p["Email"], local_date):
            send(p)
            with LOG.open("a") as f:
                f.write(json.dumps({"email": p["Email"], "local_date": local_date,
                                    "sent_utc": now_utc.isoformat()}) + "\n")
            sent.append(p["Name"])
    return sent

LOG.unlink(missing_ok=True)
fake_send = lambda p: None
now = datetime(2026, 10, 6, 7, 0, tzinfo=timezone.utc)
print(run(now, fake_send), run(now, fake_send))

The job runs at the top of every hour — 0 * * * * in cron, or an hourly trigger in Task Scheduler — and sends to whoever is due. The log records each send against the recipient's local date, which is the right key: "has Dana had today's report?" means today in New York. Running the same hour twice, as the second call shows, sends nothing the second time, so a retried or overlapping run is harmless. In the real job send builds and sends the message as in Send Each Recipient Their Own Excel Report with Python; a send that raises is not logged, so the next hourly run tries again — but only while the local hour still matches, which is the subject of the catch-up rule below.

What each hourly run does The job starts at the top of the hour in UTC, converts that moment to each recipient's time zone, checks the local send hour, weekend and holiday, checks the send log for the local date, then sends and records the send. Hourly start UTC now To local time per recipient Send hour and working day? local rules Not yet sent today? log by local date Send and log append one line

Step 4 — Prove it across a clock change

Python
from datetime import timedelta

LOG.unlink(missing_ok=True)
counts = {}
t0 = datetime(2026, 10, 23, 0, 0, tzinfo=timezone.utc)
for h in range(24 * 14):                        # two weeks, hour by hour
    for name in run(t0 + timedelta(hours=h), fake_send):
        counts[name] = counts.get(name, 0) + 1
print(counts)
for line in LOG.read_text().splitlines():
    rec = json.loads(line)
    if rec["email"] in ("priya@example.com", "dana@example.com") and rec["local_date"] in ("2026-10-23", "2026-10-26", "2026-10-30", "2026-11-02"):
        print(rec["email"].split("@")[0], rec["local_date"], "sent at", rec["sent_utc"][11:16], "UTC")

Time zone code is easy to get almost right, so it deserves a test that does not depend on the real clock. This one replays two weeks hour by hour, from 23 October to 5 November 2026 — a fortnight that contains the end of summer time in Europe (25 October) and in the United States (1 November), while Sydney, which moved its clocks forward on 4 October, is already on summer time throughout. Each person should receive exactly one report per local working day, and the log shows the UTC send time shifting by an hour when their clocks change while the local time stays at 08:00: Priya's report goes at 07:00 UTC before 25 October and 08:00 UTC after, Dana's at 12:00 UTC until 1 November and 13:00 UTC after. The counts confirm that nobody was skipped or doubled: ten working days for everyone, including Aisha in Riyadh, whose working week runs from Sunday to Thursday, and Sam in Sydney, whose 08:00 falls at 21:00 UTC on the previous calendar day. That last case is a useful reminder of why everything is decided in local time — in UTC terms Sam's Monday report goes out on Sunday evening.

Same local hour, different UTC time Priya in London receives the report at 07:00 UTC before the European clock change on 25 October and at 08:00 UTC after it. Dana in New York receives it at 12:00 UTC until the US change on 1 November and at 13:00 UTC afterwards. Both always receive it at 08:00 local time. A B C D 1 Recipient Local date Sent (UTC) Local time 2 Priya — London Fri 23 Oct 07:00 08:00 BST 3 Priya — London Mon 26 Oct 08:00 08:00 GMT 4 Dana — New York Fri 30 Oct 12:00 08:00 EDT 5 Dana — New York Mon 2 Nov 13:00 08:00 EST

Missed hours and catching up

The simple rule — send when the local hour equals the send hour — has one weakness: if the job does not run in that hour, because the server was down or the run failed, the recipient gets nothing that day. Two refinements fix it. The first is a window: treat a person as due from their send hour until, say, noon local time, and rely on the log to stop duplicates; a run at 10:00 then catches up anyone missed at 08:00, and a report that arrives at 10:00 is far better than none. The second is an alert: if a recipient's local working day has passed noon with no line in the log, report it, because something is wrong that the window could not cover. Both fit into due() and run() with a few lines, and the replay test is the way to check them.

Common pitfalls

  • Abbreviations or offsets in the list. Store IANA names; they follow daylight saving.
  • Checking weekends in server time. Convert to the recipient's zone first.
  • Logging by UTC date. Key the send log on the recipient's local date.
  • One cron line per zone. One hourly job scales and survives clock changes.
  • Testing against the real clock. Replay simulated hours across a clock change.

Conclusion

To deliver a report at each recipient's local morning, keep an IANA time zone, country and send hour per person, run one job every hour, convert the current UTC time to each person's zone and send when it is their hour on a local working day, and log sends by local date so reruns never duplicate. Replay a fortnight that contains a clock change to prove every person receives exactly one report per working day.

Frequently asked questions

How do I send an email at a specific local time for each recipient? Run a job hourly, convert the current UTC time to each recipient's time zone with zoneinfo, and send when the local hour matches their send hour.

Does zoneinfo handle daylight saving time? Yes; with an IANA name such as Europe/London the conversion uses the correct offset for each date, so the local hour stays the same when the clocks change.

Why do I need tzdata on Windows? Windows has no IANA time zone database; the tzdata package provides one that zoneinfo uses automatically.

How do I stop a rerun from sending the report twice? Record each send with the recipient and their local date, and skip anyone already in the log for that date.

More on email delivery

More on email delivery →