[{"data":1,"prerenderedAt":2177},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst":3,"surround:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst":2143,"ancestors:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst":2152,"children:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst":2157,"related:\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst":2158},{"id":4,"title":5,"body":6,"dateModified":2110,"datePublished":2110,"description":2111,"extension":2112,"faq":2113,"keyword":2122,"kind":2123,"meta":2124,"navigation":136,"path":2132,"seo":2133,"slug":2136,"stem":2137,"topics":2138,"type":2141,"__hash__":2142},"docs\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst\u002Findex.md","Schedule Reports Correctly Across Time Zones and DST",{"type":7,"value":8,"toc":2098},"minimark",[9,24,29,57,75,79,82,347,350,481,485,500,567,573,707,710,714,717,992,1002,1123,1127,1135,1467,1483,1622,1626,1629,1955,1970,1974,2016,2020,2033,2037,2043,2049,2055,2061,2065,2094],[10,11,12,13,17,18,23],"p",{},"The sales report is meant to land at 08:00 London time. It is scheduled with ",[14,15,16],"code",{},"0 7 * * *"," on a server that runs in UTC, and all summer it arrives at 08:00. Then the clocks go back at the end of October, and from the next morning it arrives at 07:00 — before the overnight data load has finished, so it reports a short day. Nobody changed anything; the schedule simply runs in UTC while the people reading the report live by local time. This page shows how the drift happens, how to give each common scheduler a time zone, which local times to avoid on the days the clocks change, a guard pattern for schedulers that only speak UTC, and how to compute report periods correctly when a day is 23 or 25 hours long. It belongs to ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002F","Scheduling Python Excel Scripts with Cron",".",[25,26,28],"h2",{"id":27},"prerequisites","Prerequisites",[30,31,36],"pre",{"className":32,"code":33,"language":34,"meta":35,"style":35},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install croniter pandas\n","bash","",[14,37,38],{"__ignoreMap":35},[39,40,43,47,51,54],"span",{"class":41,"line":42},"line",1,[39,44,46],{"class":45},"sMTad","pip",[39,48,50],{"class":49},"srMev"," install",[39,52,53],{"class":49}," croniter",[39,55,56],{"class":49}," pandas\n",[10,58,59,60,63,64,67,68,71,72,74],{},"The examples use Python's built-in ",[14,61,62],{},"zoneinfo"," for time zones and ",[14,65,66],{},"croniter"," to compute when a cron expression fires. On Windows, also ",[14,69,70],{},"pip install tzdata",", because Windows does not ship the IANA time zone database that ",[14,73,62],{}," reads.",[25,76,78],{"id":77},"step-1-see-the-drift","Step 1 — See the drift",[10,80,81],{},"Compute when a UTC cron entry fires around the October clock change, and what time that is in London:",[30,83,87],{"className":84,"code":85,"language":86,"meta":35,"style":35},"language-python shiki shiki-themes github-light github-dark-high-contrast","from datetime import datetime\nfrom zoneinfo import ZoneInfo\nfrom croniter import croniter\n\nUTC, LONDON = ZoneInfo(\"UTC\"), ZoneInfo(\"Europe\u002FLondon\")\n\nruns = croniter(\"0 7 * * *\", datetime(2026, 10, 22, 12, tzinfo=UTC))\nfor _ in range(6):\n    t = runs.get_next(datetime)\n    print(f\"{t:%a %d %b %H:%M} UTC  →  {t.astimezone(LONDON):%H:%M %Z} in London\")\n","python",[14,88,89,105,118,131,138,169,174,223,247,258],{"__ignoreMap":35},[39,90,91,95,99,102],{"class":41,"line":42},[39,92,94],{"class":93},"s-kum","from",[39,96,98],{"class":97},"skGVy"," datetime ",[39,100,101],{"class":93},"import",[39,103,104],{"class":97}," datetime\n",[39,106,108,110,113,115],{"class":41,"line":107},2,[39,109,94],{"class":93},[39,111,112],{"class":97}," zoneinfo ",[39,114,101],{"class":93},[39,116,117],{"class":97}," ZoneInfo\n",[39,119,121,123,126,128],{"class":41,"line":120},3,[39,122,94],{"class":93},[39,124,125],{"class":97}," croniter ",[39,127,101],{"class":93},[39,129,130],{"class":97}," croniter\n",[39,132,134],{"class":41,"line":133},4,[39,135,137],{"emptyLinePlaceholder":136},true,"\n",[39,139,141,145,148,151,154,157,160,163,166],{"class":41,"line":140},5,[39,142,144],{"class":143},"sP0c6","UTC",[39,146,147],{"class":97},", ",[39,149,150],{"class":143},"LONDON",[39,152,153],{"class":93}," =",[39,155,156],{"class":97}," ZoneInfo(",[39,158,159],{"class":49},"\"UTC\"",[39,161,162],{"class":97},"), ZoneInfo(",[39,164,165],{"class":49},"\"Europe\u002FLondon\"",[39,167,168],{"class":97},")\n",[39,170,172],{"class":41,"line":171},6,[39,173,137],{"emptyLinePlaceholder":136},[39,175,177,180,183,186,189,192,195,197,200,202,205,207,210,212,216,218,220],{"class":41,"line":176},7,[39,178,179],{"class":97},"runs ",[39,181,182],{"class":93},"=",[39,184,185],{"class":97}," croniter(",[39,187,188],{"class":49},"\"0 7 * * *\"",[39,190,191],{"class":97},", datetime(",[39,193,194],{"class":143},"2026",[39,196,147],{"class":97},[39,198,199],{"class":143},"10",[39,201,147],{"class":97},[39,203,204],{"class":143},"22",[39,206,147],{"class":97},[39,208,209],{"class":143},"12",[39,211,147],{"class":97},[39,213,215],{"class":214},"sa561","tzinfo",[39,217,182],{"class":93},[39,219,144],{"class":143},[39,221,222],{"class":97},"))\n",[39,224,226,229,232,235,238,241,244],{"class":41,"line":225},8,[39,227,228],{"class":93},"for",[39,230,231],{"class":97}," _ ",[39,233,234],{"class":93},"in",[39,236,237],{"class":143}," range",[39,239,240],{"class":97},"(",[39,242,243],{"class":143},"6",[39,245,246],{"class":97},"):\n",[39,248,250,253,255],{"class":41,"line":249},9,[39,251,252],{"class":97},"    t ",[39,254,182],{"class":93},[39,256,257],{"class":97}," runs.get_next(datetime)\n",[39,259,261,264,266,269,272,276,279,282,285,288,290,293,295,298,300,303,306,309,312,315,317,320,322,325,327,330,332,335,337,340,342,345],{"class":41,"line":260},10,[39,262,263],{"class":143},"    print",[39,265,240],{"class":97},[39,267,268],{"class":93},"f",[39,270,271],{"class":49},"\"",[39,273,275],{"class":274},"sSjpA","{",[39,277,278],{"class":97},"t",[39,280,281],{"class":93},":",[39,283,284],{"class":97},"%",[39,286,287],{"class":93},"a ",[39,289,284],{"class":97},[39,291,292],{"class":93},"d ",[39,294,284],{"class":97},[39,296,297],{"class":93},"b ",[39,299,284],{"class":97},[39,301,302],{"class":93},"H",[39,304,305],{"class":97},":%",[39,307,308],{"class":93},"M",[39,310,311],{"class":274},"}",[39,313,314],{"class":49}," UTC  →  ",[39,316,275],{"class":274},[39,318,319],{"class":97},"t.astimezone(",[39,321,150],{"class":143},[39,323,324],{"class":97},"):",[39,326,284],{"class":93},[39,328,329],{"class":97},"H:",[39,331,284],{"class":93},[39,333,334],{"class":97},"M ",[39,336,284],{"class":93},[39,338,339],{"class":97},"Z",[39,341,311],{"class":274},[39,343,344],{"class":49}," in London\"",[39,346,168],{"class":97},[10,348,349],{},"Until Sunday 25 October the 07:00 UTC run is 08:00 British Summer Time; from that day on it is 07:00 Greenwich Mean Time. In March the opposite happens and a 08:00 GMT schedule moves to 09:00. The fix is not to edit the cron line twice a year — someone will forget, and the change has to happen at exactly the right moment — but to tell the scheduler which time zone the schedule means.",[351,352,360,361,360,365,360,369,360,377,360,384,360,393,360,400,360,405,360,409,360,413,360,417,360,422,360,425,360,427,360,429,360,434,360,440,360,444,360,447,360,449,360,451,360,456,360,461,360,464,360,466,360,468,360,471,360,475,360,477,360,479],"svg",{"viewBox":353,"role":354,"ariaLabelledBy":355,"xmlns":358,"style":359},"4 0 752 245","img",[356,357],"tz-drift-t","tz-drift-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[362,363,364],"title",{"id":356},"A 07:00 UTC schedule seen from London",[366,367,368],"desc",{"id":357},"Before the clocks go back on 25 October 2026, a 07:00 UTC run lands at 08:00 BST. From 25 October it lands at 07:00 GMT, an hour earlier for the readers, although the cron line never changed.",[370,371],"rect",{"x":372,"y":373,"width":374,"height":375,"fill":376},"4","0","752","245","#ffffff",[41,378],{"x1":379,"y1":380,"x2":381,"y2":380,"stroke":382,"style":383},"76.0","122.5","684.0","var(--muted,#5b6780)","stroke-width:2.5px",[370,385],{"x":386,"y":387,"width":388,"height":389,"rx":209,"fill":390,"stroke":391,"style":392},"20.0","16.0","136.0","74.5","#d9f4f1","var(--teal,#0f9488)","stroke-width:2px",[394,395,399],"text",{"x":396,"y":397,"style":398},"88.0","49.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Fri 23 Oct",[394,401,404],{"x":396,"y":402,"style":403},"67.5","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","08:00 BST",[41,406],{"x1":396,"y1":407,"x2":396,"y2":380,"stroke":382,"style":408},"90.5","stroke-width:1.5px",[410,411],"circle",{"cx":396,"cy":380,"r":412,"fill":391,"stroke":376,"style":392},"7.0",[370,414],{"x":415,"y":416,"width":388,"height":389,"rx":209,"fill":390,"stroke":391,"style":392},"166.0","154.5",[394,418,421],{"x":419,"y":420,"style":398},"234.0","187.5","Sat 24 Oct",[394,423,404],{"x":419,"y":424,"style":403},"206.0",[41,426],{"x1":419,"y1":380,"x2":419,"y2":416,"stroke":382,"style":408},[410,428],{"cx":419,"cy":380,"r":412,"fill":391,"stroke":376,"style":392},[370,430],{"x":431,"y":387,"width":388,"height":389,"rx":209,"fill":432,"stroke":433,"style":392},"312.0","#fdefd8","var(--gold,#b4740a)",[394,435,439],{"x":436,"y":437,"style":438},"380.0","40.5","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Sun 25 Oct",[394,441,443],{"x":436,"y":442,"style":403},"59.0","07:00 GMT · clocks",[394,445,446],{"x":436,"y":379,"style":403},"back",[41,448],{"x1":436,"y1":407,"x2":436,"y2":380,"stroke":382,"style":408},[410,450],{"cx":436,"cy":380,"r":412,"fill":433,"stroke":376,"style":392},[370,452],{"x":453,"y":416,"width":388,"height":389,"rx":209,"fill":454,"stroke":455,"style":392},"458.0","#fce9e9","var(--danger,#dc2626)",[394,457,460],{"x":458,"y":420,"style":459},"526.0","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","Mon 26 Oct",[394,462,463],{"x":458,"y":424,"style":403},"07:00 GMT",[41,465],{"x1":458,"y1":380,"x2":458,"y2":416,"stroke":382,"style":408},[410,467],{"cx":458,"cy":380,"r":412,"fill":455,"stroke":376,"style":392},[370,469],{"x":470,"y":387,"width":388,"height":389,"rx":209,"fill":454,"stroke":455,"style":392},"604.0",[394,472,474],{"x":473,"y":397,"style":459},"672.0","Tue 27 Oct",[394,476,463],{"x":473,"y":402,"style":403},[41,478],{"x1":473,"y1":407,"x2":473,"y2":380,"stroke":382,"style":408},[410,480],{"cx":473,"cy":380,"r":412,"fill":455,"stroke":376,"style":392},[25,482,484],{"id":483},"step-2-give-the-scheduler-a-time-zone","Step 2 — Give the scheduler a time zone",[10,486,487,488,491,492,495,496,499],{},"Most schedulers can interpret a schedule in a named time zone. Use the IANA name such as ",[14,489,490],{},"Europe\u002FLondon"," or ",[14,493,494],{},"America\u002FNew_York"," — never a fixed offset like ",[14,497,498],{},"+01:00",", which does not follow daylight saving:",[501,502,503,527,541,551,557],"ul",{},[504,505,506,510,511,514,515,518,519,522,523,23],"li",{},[507,508,509],"strong",{},"systemd timers"," accept a time zone at the end of ",[14,512,513],{},"OnCalendar",": ",[14,516,517],{},"OnCalendar=Mon..Fri 08:00 Europe\u002FLondon",". Check it with ",[14,520,521],{},"systemd-analyze calendar \"Mon..Fri 08:00 Europe\u002FLondon\"",", which prints the next run in local and UTC time. See ",[19,524,526],{"href":525},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-an-excel-report-with-systemd-timers\u002F","Schedule an Excel Report with systemd Timers",[504,528,529,532,533,536,537,540],{},[507,530,531],{},"cronie",", the cron of Fedora, RHEL and many other distributions, supports a ",[14,534,535],{},"CRON_TZ=Europe\u002FLondon"," line in the crontab that applies to the entries below it. Debian and Ubuntu's classic cron does not, and silently ignores it — check ",[14,538,539],{},"man 5 crontab"," on your server.",[504,542,543,546,547,550],{},[507,544,545],{},"Kubernetes CronJobs"," take ",[14,548,549],{},"spec.timeZone: \"Europe\u002FLondon\""," (stable since Kubernetes 1.27).",[504,552,553,556],{},[507,554,555],{},"APScheduler and Celery beat"," take a time zone on the trigger or the app configuration.",[504,558,559,562,563,566],{},[507,560,561],{},"GitHub Actions"," ",[14,564,565],{},"schedule"," triggers run in UTC only; use the guard in Step 4.",[10,568,569,570,572],{},"In Python, the same computation ",[14,571,66],{}," did above becomes time-zone-aware simply by starting from a local time:",[30,574,576],{"className":84,"code":575,"language":86,"meta":35,"style":35},"runs = croniter(\"0 8 * * *\", datetime(2026, 10, 22, 12, tzinfo=LONDON))\nfor _ in range(6):\n    t = runs.get_next(datetime)\n    print(f\"{t:%a %d %b %H:%M %Z}  =  {t.astimezone(UTC):%H:%M} UTC\")\n",[14,577,578,615,631,639],{"__ignoreMap":35},[39,579,580,582,584,586,589,591,593,595,597,599,601,603,605,607,609,611,613],{"class":41,"line":42},[39,581,179],{"class":97},[39,583,182],{"class":93},[39,585,185],{"class":97},[39,587,588],{"class":49},"\"0 8 * * *\"",[39,590,191],{"class":97},[39,592,194],{"class":143},[39,594,147],{"class":97},[39,596,199],{"class":143},[39,598,147],{"class":97},[39,600,204],{"class":143},[39,602,147],{"class":97},[39,604,209],{"class":143},[39,606,147],{"class":97},[39,608,215],{"class":214},[39,610,182],{"class":93},[39,612,150],{"class":143},[39,614,222],{"class":97},[39,616,617,619,621,623,625,627,629],{"class":41,"line":107},[39,618,228],{"class":93},[39,620,231],{"class":97},[39,622,234],{"class":93},[39,624,237],{"class":143},[39,626,240],{"class":97},[39,628,243],{"class":143},[39,630,246],{"class":97},[39,632,633,635,637],{"class":41,"line":120},[39,634,252],{"class":97},[39,636,182],{"class":93},[39,638,257],{"class":97},[39,640,641,643,645,647,649,651,653,655,657,659,661,663,665,667,669,671,673,675,677,679,681,684,686,688,690,692,694,696,698,700,702,705],{"class":41,"line":133},[39,642,263],{"class":143},[39,644,240],{"class":97},[39,646,268],{"class":93},[39,648,271],{"class":49},[39,650,275],{"class":274},[39,652,278],{"class":97},[39,654,281],{"class":93},[39,656,284],{"class":97},[39,658,287],{"class":93},[39,660,284],{"class":97},[39,662,292],{"class":93},[39,664,284],{"class":97},[39,666,297],{"class":93},[39,668,284],{"class":97},[39,670,302],{"class":93},[39,672,305],{"class":97},[39,674,334],{"class":93},[39,676,284],{"class":97},[39,678,339],{"class":93},[39,680,311],{"class":274},[39,682,683],{"class":49},"  =  ",[39,685,275],{"class":274},[39,687,319],{"class":97},[39,689,144],{"class":143},[39,691,324],{"class":97},[39,693,284],{"class":93},[39,695,329],{"class":97},[39,697,284],{"class":93},[39,699,308],{"class":97},[39,701,311],{"class":274},[39,703,704],{"class":49}," UTC\"",[39,706,168],{"class":97},[10,708,709],{},"Now the run stays at 08:00 local time through the change, and it is the UTC time that moves from 07:00 to 08:00. That is what a scheduler configured with a time zone does internally.",[25,711,713],{"id":712},"step-3-avoid-the-skipped-and-repeated-hours","Step 3 — Avoid the skipped and repeated hours",[10,715,716],{},"On the two change days, part of the night behaves strangely. In spring, local time jumps from 01:00 to 02:00 in London — times in between never happen. In autumn, 01:00 to 02:00 happens twice. Python shows both:",[30,718,720],{"className":84,"code":719,"language":86,"meta":35,"style":35},"from datetime import timedelta\n\ngap = datetime(2026, 3, 29, 1, 30, tzinfo=LONDON)          # does not exist\nprint(\"01:30 on 29 Mar  →\", gap.astimezone(UTC).astimezone(LONDON).strftime(\"%H:%M %Z\"), \"after a round trip\")\n\nfirst = datetime(2026, 10, 25, 1, 30, tzinfo=LONDON, fold=0)\nsecond = datetime(2026, 10, 25, 1, 30, tzinfo=LONDON, fold=1)\nprint(\"01:30 on 25 Oct  →\", first.astimezone(UTC).strftime(\"%H:%M UTC\"), \"and\", second.astimezone(UTC).strftime(\"%H:%M UTC\"))\nprint(\"hours between them:\", (second.astimezone(UTC) - first.astimezone(UTC)) \u002F timedelta(hours=1))\n",[14,721,722,733,737,784,818,822,869,914,949],{"__ignoreMap":35},[39,723,724,726,728,730],{"class":41,"line":42},[39,725,94],{"class":93},[39,727,98],{"class":97},[39,729,101],{"class":93},[39,731,732],{"class":97}," timedelta\n",[39,734,735],{"class":41,"line":107},[39,736,137],{"emptyLinePlaceholder":136},[39,738,739,742,744,747,749,751,754,756,759,761,764,766,769,771,773,775,777,780],{"class":41,"line":120},[39,740,741],{"class":97},"gap ",[39,743,182],{"class":93},[39,745,746],{"class":97}," datetime(",[39,748,194],{"class":143},[39,750,147],{"class":97},[39,752,753],{"class":143},"3",[39,755,147],{"class":97},[39,757,758],{"class":143},"29",[39,760,147],{"class":97},[39,762,763],{"class":143},"1",[39,765,147],{"class":97},[39,767,768],{"class":143},"30",[39,770,147],{"class":97},[39,772,215],{"class":214},[39,774,182],{"class":93},[39,776,150],{"class":143},[39,778,779],{"class":97},")          ",[39,781,783],{"class":782},"s-wDw","# does not exist\n",[39,785,786,789,791,794,797,799,802,804,807,810,813,816],{"class":41,"line":133},[39,787,788],{"class":143},"print",[39,790,240],{"class":97},[39,792,793],{"class":49},"\"01:30 on 29 Mar  →\"",[39,795,796],{"class":97},", gap.astimezone(",[39,798,144],{"class":143},[39,800,801],{"class":97},").astimezone(",[39,803,150],{"class":143},[39,805,806],{"class":97},").strftime(",[39,808,809],{"class":49},"\"%H:%M %Z\"",[39,811,812],{"class":97},"), ",[39,814,815],{"class":49},"\"after a round trip\"",[39,817,168],{"class":97},[39,819,820],{"class":41,"line":140},[39,821,137],{"emptyLinePlaceholder":136},[39,823,824,827,829,831,833,835,837,839,842,844,846,848,850,852,854,856,858,860,863,865,867],{"class":41,"line":171},[39,825,826],{"class":97},"first ",[39,828,182],{"class":93},[39,830,746],{"class":97},[39,832,194],{"class":143},[39,834,147],{"class":97},[39,836,199],{"class":143},[39,838,147],{"class":97},[39,840,841],{"class":143},"25",[39,843,147],{"class":97},[39,845,763],{"class":143},[39,847,147],{"class":97},[39,849,768],{"class":143},[39,851,147],{"class":97},[39,853,215],{"class":214},[39,855,182],{"class":93},[39,857,150],{"class":143},[39,859,147],{"class":97},[39,861,862],{"class":214},"fold",[39,864,182],{"class":93},[39,866,373],{"class":143},[39,868,168],{"class":97},[39,870,871,874,876,878,880,882,884,886,888,890,892,894,896,898,900,902,904,906,908,910,912],{"class":41,"line":176},[39,872,873],{"class":97},"second ",[39,875,182],{"class":93},[39,877,746],{"class":97},[39,879,194],{"class":143},[39,881,147],{"class":97},[39,883,199],{"class":143},[39,885,147],{"class":97},[39,887,841],{"class":143},[39,889,147],{"class":97},[39,891,763],{"class":143},[39,893,147],{"class":97},[39,895,768],{"class":143},[39,897,147],{"class":97},[39,899,215],{"class":214},[39,901,182],{"class":93},[39,903,150],{"class":143},[39,905,147],{"class":97},[39,907,862],{"class":214},[39,909,182],{"class":93},[39,911,763],{"class":143},[39,913,168],{"class":97},[39,915,916,918,920,923,926,928,930,933,935,938,941,943,945,947],{"class":41,"line":225},[39,917,788],{"class":143},[39,919,240],{"class":97},[39,921,922],{"class":49},"\"01:30 on 25 Oct  →\"",[39,924,925],{"class":97},", first.astimezone(",[39,927,144],{"class":143},[39,929,806],{"class":97},[39,931,932],{"class":49},"\"%H:%M UTC\"",[39,934,812],{"class":97},[39,936,937],{"class":49},"\"and\"",[39,939,940],{"class":97},", second.astimezone(",[39,942,144],{"class":143},[39,944,806],{"class":97},[39,946,932],{"class":49},[39,948,222],{"class":97},[39,950,951,953,955,958,961,963,966,969,972,974,977,980,983,986,988,990],{"class":41,"line":249},[39,952,788],{"class":143},[39,954,240],{"class":97},[39,956,957],{"class":49},"\"hours between them:\"",[39,959,960],{"class":97},", (second.astimezone(",[39,962,144],{"class":143},[39,964,965],{"class":97},") ",[39,967,968],{"class":93},"-",[39,970,971],{"class":97}," first.astimezone(",[39,973,144],{"class":143},[39,975,976],{"class":97},")) ",[39,978,979],{"class":93},"\u002F",[39,981,982],{"class":97}," timedelta(",[39,984,985],{"class":214},"hours",[39,987,182],{"class":93},[39,989,763],{"class":143},[39,991,222],{"class":97},[10,993,994,995,997,998,1001],{},"A local 01:30 on the spring-forward day does not exist; Python resolves it to 02:30 BST. On the autumn day, 01:30 exists twice, an hour apart — ",[14,996,862],{}," chooses which. Schedulers handle these cases differently: some skip a job in the gap, some run it late, and some run a job in the overlap twice. The simple, portable rule is to ",[507,999,1000],{},"schedule nothing between 01:00 and 03:00 local time"," in zones that observe daylight saving. Most zones change in that window — the exact hour differs, so check yours. An 08:00 report is never affected; a nightly 02:00 job is.",[351,1003,360,1008,360,1011,360,1014,360,1017,360,1022,360,1033,360,1039,360,1043,360,1049,360,1054,360,1058,360,1062,360,1067,360,1074,360,1078,360,1083,360,1087,360,1091,360,1095,360,1098,360,1105,360,1110,360,1114,360,1117,360,1120],{"viewBox":1004,"role":354,"ariaLabelledBy":1005,"xmlns":358,"style":359},"4 0 752 226",[1006,1007],"tz-gap-t","tz-gap-d",[362,1009,1010],{"id":1006},"The two awkward nights of the year",[366,1012,1013],{"id":1007},"On the spring change, local times between 01:00 and 02:00 in London do not exist and a job scheduled then may be skipped or moved. On the autumn change, 01:00 to 02:00 happens twice and a job may run twice. Scheduling outside 01:00 to 03:00 avoids both.",[370,1015],{"x":372,"y":373,"width":374,"height":1016,"fill":376},"226",[370,1018],{"x":386,"y":387,"width":1019,"height":1020,"rx":1021,"fill":432,"stroke":433,"style":392},"228.0","194.0","14",[1023,1024,1026],"g",{"transform":1025},"translate(121.0,29.0) scale(1.083)",[1027,1028],"path",{"d":1029,"fill":1030,"stroke":1031,"style":1032},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","none","var(--gold-ink,#7a4e06)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[394,1034,1038],{"x":1035,"y":1036,"style":1037},"134.0","78.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","spring forward",[394,1040,1042],{"x":1035,"y":1041,"style":1037},"97.0","(29 Mar 2026)",[41,1044],{"x1":1045,"y1":1046,"x2":1047,"y2":1046,"stroke":433,"style":1048},"36.0","110.0","232.0","stroke-width:1px",[394,1050,1053],{"x":1035,"y":1051,"style":1052},"130.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","01:00 → 02:00 skipped",[394,1055,1057],{"x":1035,"y":1056,"style":1052},"156.0","01:30 never happens",[394,1059,1061],{"x":1035,"y":1060,"style":1052},"182.0","job may be skipped or late",[370,1063],{"x":1064,"y":387,"width":1019,"height":1020,"rx":1021,"fill":1065,"stroke":1066,"style":392},"266.0","#fee8f2","var(--accent,#f43f8f)",[1023,1068,1070],{"transform":1069},"translate(367.0,29.0) scale(1.083)",[1027,1071],{"d":1072,"fill":1030,"stroke":1073,"style":1032},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--accent-ink,#be185d)",[394,1075,1077],{"x":436,"y":1036,"style":1076},"font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","fall back (25 Oct 2026)",[41,1079],{"x1":1080,"y1":1081,"x2":1082,"y2":1081,"stroke":1066,"style":1048},"282.0","91.0","478.0",[394,1084,1086],{"x":436,"y":1085,"style":1052},"111.0","01:00 – 02:00 happens twice",[394,1088,1090],{"x":436,"y":1089,"style":1052},"137.0","01:30 occurs twice",[394,1092,1094],{"x":436,"y":1093,"style":1052},"163.0","job may run twice",[370,1096],{"x":1097,"y":387,"width":1019,"height":1020,"rx":1021,"fill":390,"stroke":391,"style":392},"512.0",[1023,1099,1101],{"transform":1100},"translate(613.0,29.0) scale(1.083)",[1027,1102],{"d":1103,"fill":1030,"stroke":1104,"style":1032},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[394,1106,1109],{"x":1107,"y":1036,"style":1108},"626.0","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","the safe rule",[41,1111],{"x1":1112,"y1":1081,"x2":1113,"y2":1081,"stroke":391,"style":1048},"528.0","724.0",[394,1115,1116],{"x":1107,"y":1085,"style":1052},"nothing 01:00 – 03:00 local",[394,1118,1119],{"x":1107,"y":1089,"style":1052},"08:00 reports unaffected",[394,1121,1122],{"x":1107,"y":1093,"style":1052},"check your zone's hour",[25,1124,1126],{"id":1125},"step-4-a-guard-for-utc-only-schedulers","Step 4 — A guard for UTC-only schedulers",[10,1128,1129,1130,1134],{},"When the scheduler can only do UTC — GitHub Actions, some hosted cron services, Debian's cron — schedule the job at ",[1131,1132,1133],"em",{},"both"," UTC times the local time can map to, and let the script decide whether this is the right one:",[30,1136,1138],{"className":84,"code":1137,"language":86,"meta":35,"style":35},"from pathlib import Path\n\ndef should_run(now_utc, local_hour=8, tz=LONDON, marker_dir=Path(\"markers\")):\n    local = now_utc.astimezone(tz)\n    if local.hour != local_hour:\n        return False, f\"{local:%H:%M %Z} is not {local_hour:02d}:00 local\"\n    marker = marker_dir \u002F f\"report_{local:%Y-%m-%d}.done\"\n    if marker.exists():\n        return False, \"already ran today\"\n    marker_dir.mkdir(exist_ok=True)\n    marker.touch()\n    return True, f\"running at {local:%H:%M %Z}\"\n\n# The crontab has: 0 7,8 * * *  (both UTC hours that can be 08:00 in London)\nfor ts in [\"2026-10-24 07:00\", \"2026-10-24 08:00\", \"2026-10-26 07:00\", \"2026-10-26 08:00\"]:\n    now = datetime.fromisoformat(ts).replace(tzinfo=UTC)\n    print(ts, \"UTC →\", should_run(now))\n",[14,1139,1140,1152,1156,1194,1204,1218,1269,1313,1320,1331,1346,1352,1389,1394,1400,1434,1453],{"__ignoreMap":35},[39,1141,1142,1144,1147,1149],{"class":41,"line":42},[39,1143,94],{"class":93},[39,1145,1146],{"class":97}," pathlib ",[39,1148,101],{"class":93},[39,1150,1151],{"class":97}," Path\n",[39,1153,1154],{"class":41,"line":107},[39,1155,137],{"emptyLinePlaceholder":136},[39,1157,1158,1161,1165,1168,1170,1173,1176,1178,1180,1183,1185,1188,1191],{"class":41,"line":120},[39,1159,1160],{"class":93},"def",[39,1162,1164],{"class":1163},"s_Opv"," should_run",[39,1166,1167],{"class":97},"(now_utc, local_hour",[39,1169,182],{"class":93},[39,1171,1172],{"class":143},"8",[39,1174,1175],{"class":97},", tz",[39,1177,182],{"class":93},[39,1179,150],{"class":143},[39,1181,1182],{"class":97},", marker_dir",[39,1184,182],{"class":93},[39,1186,1187],{"class":97},"Path(",[39,1189,1190],{"class":49},"\"markers\"",[39,1192,1193],{"class":97},")):\n",[39,1195,1196,1199,1201],{"class":41,"line":133},[39,1197,1198],{"class":97},"    local ",[39,1200,182],{"class":93},[39,1202,1203],{"class":97}," now_utc.astimezone(tz)\n",[39,1205,1206,1209,1212,1215],{"class":41,"line":140},[39,1207,1208],{"class":93},"    if",[39,1210,1211],{"class":97}," local.hour ",[39,1213,1214],{"class":93},"!=",[39,1216,1217],{"class":97}," local_hour:\n",[39,1219,1220,1223,1226,1228,1230,1232,1234,1237,1239,1241,1243,1245,1247,1249,1251,1253,1256,1258,1261,1264,1266],{"class":41,"line":171},[39,1221,1222],{"class":93},"        return",[39,1224,1225],{"class":143}," False",[39,1227,147],{"class":97},[39,1229,268],{"class":93},[39,1231,271],{"class":49},[39,1233,275],{"class":274},[39,1235,1236],{"class":97},"local",[39,1238,281],{"class":93},[39,1240,284],{"class":97},[39,1242,302],{"class":93},[39,1244,305],{"class":97},[39,1246,334],{"class":93},[39,1248,284],{"class":97},[39,1250,339],{"class":93},[39,1252,311],{"class":274},[39,1254,1255],{"class":49}," is not ",[39,1257,275],{"class":274},[39,1259,1260],{"class":97},"local_hour",[39,1262,1263],{"class":93},":02d",[39,1265,311],{"class":274},[39,1267,1268],{"class":49},":00 local\"\n",[39,1270,1271,1274,1276,1279,1281,1284,1287,1289,1292,1294,1297,1300,1303,1305,1308,1310],{"class":41,"line":176},[39,1272,1273],{"class":97},"    marker ",[39,1275,182],{"class":93},[39,1277,1278],{"class":97}," marker_dir ",[39,1280,979],{"class":93},[39,1282,1283],{"class":93}," f",[39,1285,1286],{"class":49},"\"report_",[39,1288,275],{"class":274},[39,1290,1291],{"class":97},"local:",[39,1293,284],{"class":93},[39,1295,1296],{"class":97},"Y",[39,1298,1299],{"class":93},"-%",[39,1301,1302],{"class":97},"m",[39,1304,1299],{"class":93},[39,1306,1307],{"class":97},"d",[39,1309,311],{"class":274},[39,1311,1312],{"class":49},".done\"\n",[39,1314,1315,1317],{"class":41,"line":225},[39,1316,1208],{"class":93},[39,1318,1319],{"class":97}," marker.exists():\n",[39,1321,1322,1324,1326,1328],{"class":41,"line":249},[39,1323,1222],{"class":93},[39,1325,1225],{"class":143},[39,1327,147],{"class":97},[39,1329,1330],{"class":49},"\"already ran today\"\n",[39,1332,1333,1336,1339,1341,1344],{"class":41,"line":260},[39,1334,1335],{"class":97},"    marker_dir.mkdir(",[39,1337,1338],{"class":214},"exist_ok",[39,1340,182],{"class":93},[39,1342,1343],{"class":143},"True",[39,1345,168],{"class":97},[39,1347,1349],{"class":41,"line":1348},11,[39,1350,1351],{"class":97},"    marker.touch()\n",[39,1353,1355,1358,1361,1363,1365,1368,1370,1372,1374,1376,1378,1380,1382,1384,1386],{"class":41,"line":1354},12,[39,1356,1357],{"class":93},"    return",[39,1359,1360],{"class":143}," True",[39,1362,147],{"class":97},[39,1364,268],{"class":93},[39,1366,1367],{"class":49},"\"running at ",[39,1369,275],{"class":274},[39,1371,1291],{"class":97},[39,1373,284],{"class":93},[39,1375,329],{"class":97},[39,1377,284],{"class":93},[39,1379,334],{"class":97},[39,1381,284],{"class":93},[39,1383,339],{"class":97},[39,1385,311],{"class":274},[39,1387,1388],{"class":49},"\"\n",[39,1390,1392],{"class":41,"line":1391},13,[39,1393,137],{"emptyLinePlaceholder":136},[39,1395,1397],{"class":41,"line":1396},14,[39,1398,1399],{"class":782},"# The crontab has: 0 7,8 * * *  (both UTC hours that can be 08:00 in London)\n",[39,1401,1403,1405,1408,1410,1413,1416,1418,1421,1423,1426,1428,1431],{"class":41,"line":1402},15,[39,1404,228],{"class":93},[39,1406,1407],{"class":97}," ts ",[39,1409,234],{"class":93},[39,1411,1412],{"class":97}," [",[39,1414,1415],{"class":49},"\"2026-10-24 07:00\"",[39,1417,147],{"class":97},[39,1419,1420],{"class":49},"\"2026-10-24 08:00\"",[39,1422,147],{"class":97},[39,1424,1425],{"class":49},"\"2026-10-26 07:00\"",[39,1427,147],{"class":97},[39,1429,1430],{"class":49},"\"2026-10-26 08:00\"",[39,1432,1433],{"class":97},"]:\n",[39,1435,1437,1440,1442,1445,1447,1449,1451],{"class":41,"line":1436},16,[39,1438,1439],{"class":97},"    now ",[39,1441,182],{"class":93},[39,1443,1444],{"class":97}," datetime.fromisoformat(ts).replace(",[39,1446,215],{"class":214},[39,1448,182],{"class":93},[39,1450,144],{"class":143},[39,1452,168],{"class":97},[39,1454,1456,1458,1461,1464],{"class":41,"line":1455},17,[39,1457,263],{"class":143},[39,1459,1460],{"class":97},"(ts, ",[39,1462,1463],{"class":49},"\"UTC →\"",[39,1465,1466],{"class":97},", should_run(now))\n",[10,1468,1469,1470,1474,1475,1478,1479,1482],{},"In summer the 07:00 UTC run is 08:00 BST and goes ahead, and the 08:00 UTC run is 09:00 and exits immediately; after the change it is the other way round. The marker file guards against running twice on the same local date if a scheduler retries. In CI, where no file survives between runs, check whether today's report already exists in its destination instead — see ",[19,1471,1473],{"href":1472},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-python-excel-report-in-github-actions\u002F","Run a Python Excel Report in GitHub Actions",". Passing ",[14,1476,1477],{},"now_utc"," in, rather than calling ",[14,1480,1481],{},"datetime.now()"," inside, is what makes the function testable on any date.",[351,1484,360,1489,360,1492,360,1495,360,1498,360,1505,360,1512,360,1516,360,1520,360,1524,360,1527,360,1532,360,1540,360,1544,360,1550,360,1557,360,1562,360,1567,360,1569,360,1572,360,1576,360,1579,360,1582,360,1587,360,1591,360,1594,360,1596,360,1599,360,1603,360,1606,360,1609,360,1615,360,1619],{"viewBox":1485,"role":354,"ariaLabelledBy":1486,"xmlns":358,"style":359},"4 0 752 272",[1487,1488],"tz-guard-t","tz-guard-d",[362,1490,1491],{"id":1487},"The guard at each UTC run",[366,1493,1494],{"id":1488},"The job is scheduled at 07:00 and 08:00 UTC. At each run the script converts the time to London time. If it is not 08:00 local, it exits. If today's marker exists, it exits. Otherwise it writes the marker and runs the report.",[370,1496],{"x":372,"y":373,"width":374,"height":1497,"fill":376},"272",[370,1499],{"x":1500,"y":387,"width":1501,"height":1502,"rx":209,"fill":1503,"stroke":1504,"style":392},"240.0","280.0","75.5","#f0f4ff","var(--brand,#5b5cf0)",[1023,1506,1508],{"transform":1507},"translate(368.0,29.0) scale(1.000)",[1027,1509],{"d":1029,"fill":1030,"stroke":1510,"style":1511},"var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[394,1513,1515],{"x":436,"y":1502,"style":1514},"font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Converted to local time…",[41,1517],{"x1":436,"y1":1518,"x2":436,"y2":1519,"stroke":1504,"style":392},"91.5","111.5",[41,1521],{"x1":1522,"y1":1519,"x2":1523,"y2":1519,"stroke":1504,"style":392},"134.7","625.3",[41,1525],{"x1":1522,"y1":1519,"x2":1522,"y2":1526,"stroke":1504,"style":392},"157.5",[1528,1529],"polygon",{"points":1530,"fill":1531},"134.7,161.5 129.7,152.5 139.7,152.5","#5b5cf0",[370,1533],{"x":1534,"y":1535,"width":1536,"height":1537,"rx":199,"fill":376,"stroke":1538,"style":1539},"80.9","121.5","107.4","21.0","var(--line,#cdd5e6)","stroke-width:1.2px",[394,1541,1543],{"x":1522,"y":388,"style":1542},"font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","not 08:00 local",[370,1545],{"x":386,"y":1546,"width":1547,"height":1548,"rx":209,"fill":1549,"stroke":1538,"style":392},"163.5","229.3","93.0","#e7ebef",[1023,1551,1553],{"transform":1552},"translate(122.7,176.5) scale(1.000)",[1027,1554],{"d":1555,"fill":1030,"stroke":1556,"style":1511},"M6 6l12 12 M18 6L6 18","var(--text,#172033)",[394,1558,1561],{"x":1522,"y":1559,"style":1560},"223.0","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","exit quietly",[394,1563,1566],{"x":1522,"y":1564,"style":1565},"242.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","the other UTC run is the one",[41,1568],{"x1":436,"y1":1519,"x2":436,"y2":1526,"stroke":1504,"style":392},[1528,1570],{"points":1571,"fill":1531},"380.0,161.5 375.0,152.5 385.0,152.5",[370,1573],{"x":1574,"y":1535,"width":1575,"height":1537,"rx":199,"fill":376,"stroke":1538,"style":1539},"309.2","141.6",[394,1577,1578],{"x":436,"y":388,"style":1542},"08:00, marker exists",[370,1580],{"x":1581,"y":1546,"width":1547,"height":1548,"rx":209,"fill":432,"stroke":433,"style":392},"265.3",[1023,1583,1585],{"transform":1584},"translate(368.0,176.5) scale(1.000)",[1027,1586],{"d":1103,"fill":1030,"stroke":1031,"style":1511},[394,1588,1590],{"x":436,"y":1559,"style":1589},"font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","exit",[394,1592,1593],{"x":436,"y":1564,"style":1565},"already ran today",[41,1595],{"x1":1523,"y1":1519,"x2":1523,"y2":1526,"stroke":1504,"style":392},[1528,1597],{"points":1598,"fill":1531},"625.3,161.5 620.3,152.5 630.3,152.5",[370,1600],{"x":1601,"y":1535,"width":1602,"height":1537,"rx":199,"fill":376,"stroke":1538,"style":1539},"564.8","121.1",[394,1604,1605],{"x":1523,"y":388,"style":1542},"08:00, no marker",[370,1607],{"x":1608,"y":1546,"width":1547,"height":1548,"rx":209,"fill":390,"stroke":391,"style":392},"510.7",[1023,1610,1612],{"transform":1611},"translate(613.3,176.5) scale(1.000)",[1027,1613],{"d":1614,"fill":1030,"stroke":1104,"style":1511},"M13 2L4 14h7l-1 8 9-12h-7z",[394,1616,1618],{"x":1523,"y":1559,"style":1617},"font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","run the report",[394,1620,1621],{"x":1523,"y":1564,"style":1565},"write today's marker",[25,1623,1625],{"id":1624},"step-5-compute-report-periods-in-local-time","Step 5 — Compute report periods in local time",[10,1627,1628],{},"A daily report covers \"yesterday\" — but yesterday in London starts at midnight London time, and on the change days it is not 24 hours long. Build the period in local time and convert to UTC only to query the database:",[30,1630,1632],{"className":84,"code":1631,"language":86,"meta":35,"style":35},"import pandas as pd\n\ndef day_bounds(local_day, tz=LONDON):\n    start = datetime.combine(local_day, datetime.min.time(), tzinfo=tz)\n    end = datetime.combine(local_day + timedelta(days=1), datetime.min.time(), tzinfo=tz)\n    return start.astimezone(UTC), end.astimezone(UTC)\n\nfor d in [\"2026-03-29\", \"2026-06-15\", \"2026-10-25\"]:\n    s, e = day_bounds(datetime.fromisoformat(d).date())\n    print(d, f\"{s:%d %b %H:%M} → {e:%d %b %H:%M} UTC\", f\"({(e - s) \u002F timedelta(hours=1):.0f} h)\")\n\nhours = pd.date_range(\"2026-10-25\", periods=25, freq=\"h\", tz=\"Europe\u002FLondon\")\nprint(len(hours), \"hourly rows on 25 Oct; 01:00 appears\", (hours.hour == 1).sum(), \"times\")\n",[14,1633,1634,1647,1651,1667,1684,1715,1731,1735,1761,1771,1877,1881,1923],{"__ignoreMap":35},[39,1635,1636,1638,1641,1644],{"class":41,"line":42},[39,1637,101],{"class":93},[39,1639,1640],{"class":97}," pandas ",[39,1642,1643],{"class":93},"as",[39,1645,1646],{"class":97}," pd\n",[39,1648,1649],{"class":41,"line":107},[39,1650,137],{"emptyLinePlaceholder":136},[39,1652,1653,1655,1658,1661,1663,1665],{"class":41,"line":120},[39,1654,1160],{"class":93},[39,1656,1657],{"class":1163}," day_bounds",[39,1659,1660],{"class":97},"(local_day, tz",[39,1662,182],{"class":93},[39,1664,150],{"class":143},[39,1666,246],{"class":97},[39,1668,1669,1672,1674,1677,1679,1681],{"class":41,"line":133},[39,1670,1671],{"class":97},"    start ",[39,1673,182],{"class":93},[39,1675,1676],{"class":97}," datetime.combine(local_day, datetime.min.time(), ",[39,1678,215],{"class":214},[39,1680,182],{"class":93},[39,1682,1683],{"class":97},"tz)\n",[39,1685,1686,1689,1691,1694,1697,1699,1702,1704,1706,1709,1711,1713],{"class":41,"line":140},[39,1687,1688],{"class":97},"    end ",[39,1690,182],{"class":93},[39,1692,1693],{"class":97}," datetime.combine(local_day ",[39,1695,1696],{"class":93},"+",[39,1698,982],{"class":97},[39,1700,1701],{"class":214},"days",[39,1703,182],{"class":93},[39,1705,763],{"class":143},[39,1707,1708],{"class":97},"), datetime.min.time(), ",[39,1710,215],{"class":214},[39,1712,182],{"class":93},[39,1714,1683],{"class":97},[39,1716,1717,1719,1722,1724,1727,1729],{"class":41,"line":171},[39,1718,1357],{"class":93},[39,1720,1721],{"class":97}," start.astimezone(",[39,1723,144],{"class":143},[39,1725,1726],{"class":97},"), end.astimezone(",[39,1728,144],{"class":143},[39,1730,168],{"class":97},[39,1732,1733],{"class":41,"line":176},[39,1734,137],{"emptyLinePlaceholder":136},[39,1736,1737,1739,1742,1744,1746,1749,1751,1754,1756,1759],{"class":41,"line":225},[39,1738,228],{"class":93},[39,1740,1741],{"class":97}," d ",[39,1743,234],{"class":93},[39,1745,1412],{"class":97},[39,1747,1748],{"class":49},"\"2026-03-29\"",[39,1750,147],{"class":97},[39,1752,1753],{"class":49},"\"2026-06-15\"",[39,1755,147],{"class":97},[39,1757,1758],{"class":49},"\"2026-10-25\"",[39,1760,1433],{"class":97},[39,1762,1763,1766,1768],{"class":41,"line":249},[39,1764,1765],{"class":97},"    s, e ",[39,1767,182],{"class":93},[39,1769,1770],{"class":97}," day_bounds(datetime.fromisoformat(d).date())\n",[39,1772,1773,1775,1778,1780,1782,1784,1787,1789,1791,1793,1795,1797,1799,1801,1803,1805,1807,1810,1812,1815,1817,1819,1821,1823,1825,1827,1829,1831,1833,1835,1837,1839,1841,1844,1846,1849,1851,1854,1856,1858,1860,1862,1864,1867,1870,1872,1875],{"class":41,"line":260},[39,1774,263],{"class":143},[39,1776,1777],{"class":97},"(d, ",[39,1779,268],{"class":93},[39,1781,271],{"class":49},[39,1783,275],{"class":274},[39,1785,1786],{"class":97},"s",[39,1788,281],{"class":93},[39,1790,284],{"class":97},[39,1792,292],{"class":93},[39,1794,284],{"class":97},[39,1796,297],{"class":93},[39,1798,284],{"class":97},[39,1800,302],{"class":93},[39,1802,305],{"class":97},[39,1804,308],{"class":93},[39,1806,311],{"class":274},[39,1808,1809],{"class":49}," → ",[39,1811,275],{"class":274},[39,1813,1814],{"class":97},"e",[39,1816,281],{"class":93},[39,1818,284],{"class":97},[39,1820,292],{"class":93},[39,1822,284],{"class":97},[39,1824,297],{"class":93},[39,1826,284],{"class":97},[39,1828,302],{"class":93},[39,1830,305],{"class":97},[39,1832,308],{"class":93},[39,1834,311],{"class":274},[39,1836,704],{"class":49},[39,1838,147],{"class":97},[39,1840,268],{"class":93},[39,1842,1843],{"class":49},"\"(",[39,1845,275],{"class":274},[39,1847,1848],{"class":97},"(e ",[39,1850,968],{"class":93},[39,1852,1853],{"class":97}," s) ",[39,1855,979],{"class":93},[39,1857,982],{"class":97},[39,1859,985],{"class":214},[39,1861,182],{"class":93},[39,1863,763],{"class":143},[39,1865,1866],{"class":97},")",[39,1868,1869],{"class":93},":.0f",[39,1871,311],{"class":274},[39,1873,1874],{"class":49}," h)\"",[39,1876,168],{"class":97},[39,1878,1879],{"class":41,"line":1348},[39,1880,137],{"emptyLinePlaceholder":136},[39,1882,1883,1886,1888,1891,1893,1895,1898,1900,1902,1904,1907,1909,1912,1914,1917,1919,1921],{"class":41,"line":1354},[39,1884,1885],{"class":97},"hours ",[39,1887,182],{"class":93},[39,1889,1890],{"class":97}," pd.date_range(",[39,1892,1758],{"class":49},[39,1894,147],{"class":97},[39,1896,1897],{"class":214},"periods",[39,1899,182],{"class":93},[39,1901,841],{"class":143},[39,1903,147],{"class":97},[39,1905,1906],{"class":214},"freq",[39,1908,182],{"class":93},[39,1910,1911],{"class":49},"\"h\"",[39,1913,147],{"class":97},[39,1915,1916],{"class":214},"tz",[39,1918,182],{"class":93},[39,1920,165],{"class":49},[39,1922,168],{"class":97},[39,1924,1925,1927,1929,1932,1935,1938,1941,1944,1947,1950,1953],{"class":41,"line":1391},[39,1926,788],{"class":143},[39,1928,240],{"class":97},[39,1930,1931],{"class":143},"len",[39,1933,1934],{"class":97},"(hours), ",[39,1936,1937],{"class":49},"\"hourly rows on 25 Oct; 01:00 appears\"",[39,1939,1940],{"class":97},", (hours.hour ",[39,1942,1943],{"class":93},"==",[39,1945,1946],{"class":143}," 1",[39,1948,1949],{"class":97},").sum(), ",[39,1951,1952],{"class":49},"\"times\"",[39,1954,168],{"class":97},[10,1956,1957,1958,1961,1962,1965,1966,23],{},"29 March is a 23-hour day and 25 October a 25-hour one. A report that assumes 24 hourly rows will be missing an hour in October or invent one in March, and one that queries ",[14,1959,1960],{},"00:00","–",[14,1963,1964],{},"24:00"," UTC will be an hour off in summer. Computing the bounds from local midnights gets every day right. When writing timestamps to Excel, which has no notion of time zones, convert them to local time, drop the zone, and say which zone in the header — as in ",[19,1967,1969],{"href":1968},"\u002Fgetting-started-with-python-excel-automation\u002Ftroubleshooting-common-python-excel-errors\u002Ffix-excel-does-not-support-timezones-in-datetimes\u002F","Fix \"Excel Does Not Support Timezones in Datetimes\"",[25,1971,1973],{"id":1972},"common-pitfalls","Common pitfalls",[501,1975,1976,1987,1996,2002,2008],{},[504,1977,1978,562,1981,1983,1984,1986],{},[507,1979,1980],{},"Fixed offsets instead of zone names.",[14,1982,498],{}," never changes; ",[14,1985,490],{}," does.",[504,1988,1989,1995],{},[507,1990,1991,1994],{},[14,1992,1993],{},"CRON_TZ"," on a cron that ignores it."," Debian and Ubuntu cron do not support it; verify.",[504,1997,1998,2001],{},[507,1999,2000],{},"Jobs between 01:00 and 03:00."," They are skipped or doubled on change days.",[504,2003,2004,2007],{},[507,2005,2006],{},"Assuming 24 hours in a day."," Two days a year are 23 and 25 hours long.",[504,2009,2010,2015],{},[507,2011,2012,2014],{},[14,2013,1481],{}," buried in the logic."," Pass the time in so DST cases can be tested.",[25,2017,2019],{"id":2018},"conclusion","Conclusion",[10,2021,2022,2023,2025,2026,2028,2029,2032],{},"A schedule runs in whatever time zone the scheduler uses, so a UTC entry drifts by an hour for local readers twice a year. Give the scheduler a named IANA zone — systemd ",[14,2024,513],{},", cronie ",[14,2027,1993],{},", Kubernetes ",[14,2030,2031],{},"timeZone",", or a zone-aware trigger in Python — keep jobs out of the 01:00–03:00 window, and on UTC-only schedulers run at both possible UTC hours behind a local-time guard. Compute report periods from local midnights so 23- and 25-hour days come out right.",[25,2034,2036],{"id":2035},"frequently-asked-questions","Frequently asked questions",[10,2038,2039,2042],{},[507,2040,2041],{},"Why does my cron report run an hour early or late after the clocks change?","\nThe cron entry is interpreted in the server's time zone, usually UTC, which does not observe daylight saving. The local time it corresponds to moves by an hour when daylight saving starts or ends.",[10,2044,2045,2048],{},[507,2046,2047],{},"How do I run a systemd timer in a specific time zone?","\nPut the zone name at the end of OnCalendar, for example OnCalendar=Mon..Fri 08:00 Europe\u002FLondon, and check it with systemd-analyze calendar.",[10,2050,2051,2054],{},[507,2052,2053],{},"What time should I avoid scheduling jobs because of DST?","\nAvoid 01:00 to 03:00 local time in zones with daylight saving. On change days those times are skipped or repeated, so a job may not run or may run twice.",[10,2056,2057,2060],{},[507,2058,2059],{},"How do I schedule a local-time report on GitHub Actions?","\nIts schedules are UTC only. Schedule both UTC hours that can match the local time and have the script exit unless the current local hour is the target and the report has not already run that day.",[25,2062,2064],{"id":2063},"related","Related",[501,2066,2067,2073,2080,2087],{},[504,2068,2069,2070,2072],{},"Up one level: ",[19,2071,22],{"href":21}," — running reports on a schedule.",[504,2074,2075,2079],{},[19,2076,2078],{"href":2077},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-a-report-on-the-last-business-day-of-the-month\u002F","Run a Report on the Last Business Day of the Month"," — another schedule cron cannot express.",[504,2081,2082,2086],{},[19,2083,2085],{"href":2084},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Fhandle-timezones-in-excel-timestamps-with-python\u002F","Handle Timezones in Excel Timestamps with Python"," — time zones in the data itself.",[504,2088,2089,2093],{},[19,2090,2092],{"href":2091},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Frun-an-excel-report-as-a-kubernetes-cronjob\u002F","Run an Excel Report as a Kubernetes CronJob"," — where spec.timeZone applies.",[2095,2096,2097],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}",{"title":35,"searchDepth":107,"depth":107,"links":2099},[2100,2101,2102,2103,2104,2105,2106,2107,2108,2109],{"id":27,"depth":107,"text":28},{"id":77,"depth":107,"text":78},{"id":483,"depth":107,"text":484},{"id":712,"depth":107,"text":713},{"id":1125,"depth":107,"text":1126},{"id":1624,"depth":107,"text":1625},{"id":1972,"depth":107,"text":1973},{"id":2018,"depth":107,"text":2019},{"id":2035,"depth":107,"text":2036},{"id":2063,"depth":107,"text":2064},"2026-10-02","Keep an 08:00 Excel report at 08:00 local time all year: why a UTC cron entry drifts by an hour when daylight saving starts and ends, time-zone-aware schedules in systemd, cronie, Kubernetes and Python, the skipped and repeated hours to avoid, a guard that works on UTC-only schedulers, and report periods that are 23 or 25 hours long.","md",[2114,2116,2118,2120],{"q":2041,"a":2115},"The cron entry is interpreted in the server's time zone, usually UTC, which does not observe daylight saving. The local time it corresponds to moves by an hour when daylight saving starts or ends.",{"q":2047,"a":2117},"Put the zone name at the end of OnCalendar, for example OnCalendar=Mon..Fri 08:00 Europe\u002FLondon, and check it with systemd-analyze calendar.",{"q":2053,"a":2119},"Avoid 01:00 to 03:00 local time in zones with daylight saving. On change days those times are skipped or repeated, so a job may not run or may run twice.",{"q":2059,"a":2121},"Its schedules are UTC only. Schedule both UTC hours that can match the local time and have the script exit unless the current local hour is the target and the report has not already run that day.","schedule reports correctly across time zones and dst",null,{"breadcrumb":2125},[2126,2128,2131],{"name":2127,"item":979},"Home",{"name":2129,"item":2130},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst",{"title":2134,"description":2135},"Schedule Excel Reports Across Time Zones and DST in Python","Run reports at the same local time through daylight saving changes: UTC cron drift, systemd OnCalendar and CRON_TZ time zones, the DST gap and overlap, a local-time guard for UTC schedulers, and 23- and 25-hour days.","schedule-reports-correctly-across-time-zones-and-dst","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-reports-correctly-across-time-zones-and-dst\u002Findex",[2139,2140],"scheduling","dates-and-times","how-to","NfzHJ-yNpydm8hQfniA1CxIGJdC6tj_ivu0mbkexXPc",[2144,2148],{"title":2145,"path":2146,"stem":2147,"children":-1},"Schedule Recurring Excel Reports with APScheduler","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Fschedule-recurring-excel-reports-with-apscheduler\u002Findex",{"title":2149,"path":2150,"stem":2151,"children":-1},"Trigger an Excel Report from a Webhook","\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook","automating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron\u002Ftrigger-an-excel-report-from-a-webhook\u002Findex",[2153,2155],{"path":2154,"title":2129},"\u002Fautomating-reporting-workflows",{"path":2156,"title":22},"\u002Fautomating-reporting-workflows\u002Fscheduling-python-excel-scripts-with-cron",[],[2159,2162,2165,2168,2171,2174],{"path":2160,"title":2161,"kind":2141},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fdownload-new-excel-files-from-an-sftp-server","Download New Excel Files from an SFTP Server",{"path":2163,"title":2164,"kind":2141},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fread-excel-attachments-into-a-database-nightly","Read Excel Attachments into a Database Nightly",{"path":2166,"title":2167,"kind":2141},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule","Refresh an Excel Report from a Database on a Schedule",{"path":2169,"title":2170,"kind":2141},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frun-a-report-when-an-excel-file-changes-in-onedrive","Run a Report When an Excel File Changes in OneDrive",{"path":2172,"title":2173,"kind":2141},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-report-emails-at-each-recipients-local-time","Send Report Emails at Each Recipient's Local Time",{"path":2175,"title":2176,"kind":2141},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fdetect-a-scheduled-report-that-did-not-run","Detect a Scheduled Report That Did Not Run",1790972140176]