[{"data":1,"prerenderedAt":1358},["ShallowReactive",2],{"doc:\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron":3},{"id":4,"title":5,"body":6,"description":1348,"extension":1349,"meta":1350,"navigation":257,"path":1354,"seo":1355,"stem":1356,"__hash__":1357},"docs\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002Findex.md","Schedule PyQGIS Scripts with cron",{"type":7,"value":8,"toc":1335},"minimark",[9,13,22,31,193,198,217,221,390,418,429,433,472,492,496,499,535,547,646,650,653,787,805,809,812,873,930,957,972,1076,1080,1083,1161,1165,1230,1234,1237,1241,1250,1260,1274,1280,1290,1300,1304,1331],[10,11,5],"h1",{"id":12},"schedule-pyqgis-scripts-with-cron",[14,15,16,17,21],"p",{},"Nearly every PyQGIS script that fails under cron fails for the same reason: cron does not run it the way you do. It starts with a nearly empty environment, a different working directory, no display, no ",[18,19,20],"code",{},"PATH"," beyond a bare minimum, and no interest in what the script prints. The script itself is fine — everything around it is missing.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002F","Headless QGIS and Server Automation",". It covers the wrapper script that supplies the environment, the crontab entry that calls it, locking so a slow run never overlaps the next one, and the smallest arrangement that makes a failure visible to a person.",[14,32,33],{},[34,35,40,44,48,55,72,81,91,97,102,106,110,114,118,122,128,132,135,138,141,145,149,153,159,163,166,169,172,175,178,181,188],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 280","img","Comparison of the environment an interactive shell provides against the sparse environment cron provides, with a wrapper script restoring the missing pieces","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"What cron takes away, and what the wrapper puts back",[45,46,47],"desc",{},"An interactive login shell provides a full PATH, PYTHONPATH, display variables, the working directory, locale and profile settings. Cron provides only a minimal PATH, HOME and SHELL. A wrapper script sits between cron and the Python script and restores the QGIS prefix path, the offscreen platform, the profile directory, the locale and an absolute working directory.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","280","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"cronArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Your shell was doing more for you than you thought",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"16","48","220","204","10","#edf8e9","#15803d","stroke-width:2",[73,92,96],{"x":93,"y":94,"style":95,"fill":89,"textAnchor":79},"126","74","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","your interactive shell",[73,98,101],{"x":93,"y":99,"style":100,"fill":71,"textAnchor":79},"102","text-anchor:middle;font-size:11px;font-family:sans-serif","full PATH",[73,103,105],{"x":93,"y":104,"style":100,"fill":71,"textAnchor":79},"124","PYTHONPATH",[73,107,109],{"x":93,"y":108,"style":100,"fill":71,"textAnchor":79},"146","DISPLAY",[73,111,113],{"x":93,"y":112,"style":100,"fill":71,"textAnchor":79},"168","working directory",[73,115,117],{"x":93,"y":116,"style":100,"fill":71,"textAnchor":79},"190","LANG and locale",[73,119,121],{"x":93,"y":120,"style":100,"fill":71,"textAnchor":79},"212","everything in .profile",[49,123],{"x":124,"y":84,"width":85,"height":86,"rx":87,"fill":125,"stroke":126,"style":127},"268","#fdf2e2","#b91c1c","stroke-width:2.5",[73,129,131],{"x":130,"y":94,"style":95,"fill":126,"textAnchor":79},"378","what cron hands you",[73,133,134],{"x":130,"y":99,"style":100,"fill":71,"textAnchor":79},"PATH=\u002Fusr\u002Fbin:\u002Fbin",[73,136,137],{"x":130,"y":104,"style":100,"fill":71,"textAnchor":79},"HOME",[73,139,140],{"x":130,"y":108,"style":100,"fill":71,"textAnchor":79},"SHELL",[73,142,144],{"x":130,"y":143,"style":100,"fill":126,"textAnchor":79},"172","no display",[73,146,148],{"x":130,"y":147,"style":100,"fill":126,"textAnchor":79},"194","home as working directory",[73,150,152],{"x":130,"y":151,"style":100,"fill":126,"textAnchor":79},"216","output goes nowhere useful",[49,154],{"x":155,"y":84,"width":156,"height":86,"rx":87,"fill":157,"stroke":158,"style":127},"520","224","#eef7f4","#0f766e",[73,160,162],{"x":161,"y":94,"style":95,"fill":158,"textAnchor":79},"632","the wrapper restores",[73,164,165],{"x":161,"y":99,"style":100,"fill":71,"textAnchor":79},"QT_QPA_PLATFORM=offscreen",[73,167,168],{"x":161,"y":104,"style":100,"fill":71,"textAnchor":79},"QGIS prefix + profile path",[73,170,171],{"x":161,"y":108,"style":100,"fill":71,"textAnchor":79},"PYTHONPATH for qgis.core",[73,173,174],{"x":161,"y":112,"style":100,"fill":71,"textAnchor":79},"LANG=C.UTF-8",[73,176,177],{"x":161,"y":116,"style":100,"fill":71,"textAnchor":79},"an explicit cd",[73,179,180],{"x":161,"y":120,"style":100,"fill":71,"textAnchor":79},"a log file and a lock",[182,183],"line",{"x1":184,"y1":185,"x2":186,"y2":185,"stroke":71,"style":187},"488","150","514","stroke-width:2;marker-end:url(#cronArrow)",[73,189,192],{"x":75,"y":190,"style":100,"fill":191,"textAnchor":79},"272","#59645f","Never put this logic in the crontab line itself — it belongs in a file you can run by hand",[194,195,197],"h2",{"id":196},"prerequisites","Prerequisites",[199,200,201,208,211],"ul",{},[202,203,204,205,207],"li",{},"A PyQGIS script that runs correctly when you invoke it manually, headlessly — see ",[26,206,29],{"href":28},".",[202,209,210],{},"A user account that owns the script, the data and the output directory.",[202,212,213,216],{},[18,214,215],{},"cron"," (any Linux distribution) — the same structure applies to systemd timers and Windows Task Scheduler.",[194,218,220],{"id":219},"write-the-wrapper-not-a-clever-crontab-line","Write the wrapper, not a clever crontab line",[222,223,228],"pre",{"className":224,"code":225,"language":226,"meta":227,"style":227},"language-bash shiki shiki-themes github-dark","#!\u002Fusr\u002Fbin\u002Fenv bash\nset -euo pipefail\n\nexport QT_QPA_PLATFORM=offscreen\nexport XDG_RUNTIME_DIR=\u002Ftmp\u002Fruntime-gis\nexport LANG=C.UTF-8\nexport PYTHONPATH=\u002Fusr\u002Fshare\u002Fqgis\u002Fpython:${PYTHONPATH:-}\nexport QGIS_CUSTOM_CONFIG_PATH=\u002Fsrv\u002Fgis\u002Fprofile\nexport QGIS_AUTH_PASSWORD_FILE=\u002Fsrv\u002Fgis\u002Fsecrets\u002Fmaster\n\ncd \u002Fsrv\u002Fgis\u002Fjobs\n\nexec \u002Fusr\u002Fbin\u002Fpython3 nightly_export.py --source \u002Fdata\u002Freadings.gpkg --out \u002Fsrv\u002Fgis\u002Foutput\n","bash","",[18,229,230,238,252,259,276,289,302,321,334,347,352,361,366],{"__ignoreMap":227},[231,232,234],"span",{"class":182,"line":233},1,[231,235,237],{"class":236},"sjoCn","#!\u002Fusr\u002Fbin\u002Fenv bash\n",[231,239,241,245,248],{"class":182,"line":240},2,[231,242,244],{"class":243},"sDLfK","set",[231,246,247],{"class":243}," -euo",[231,249,251],{"class":250},"sU2Wk"," pipefail\n",[231,253,255],{"class":182,"line":254},3,[231,256,258],{"emptyLinePlaceholder":257},true,"\n",[231,260,262,266,270,273],{"class":182,"line":261},4,[231,263,265],{"class":264},"snl16","export",[231,267,269],{"class":268},"s95oV"," QT_QPA_PLATFORM",[231,271,272],{"class":264},"=",[231,274,275],{"class":268},"offscreen\n",[231,277,279,281,284,286],{"class":182,"line":278},5,[231,280,265],{"class":264},[231,282,283],{"class":268}," XDG_RUNTIME_DIR",[231,285,272],{"class":264},[231,287,288],{"class":268},"\u002Ftmp\u002Fruntime-gis\n",[231,290,292,294,297,299],{"class":182,"line":291},6,[231,293,265],{"class":264},[231,295,296],{"class":268}," LANG",[231,298,272],{"class":264},[231,300,301],{"class":268},"C.UTF-8\n",[231,303,305,307,310,312,315,318],{"class":182,"line":304},7,[231,306,265],{"class":264},[231,308,309],{"class":268}," PYTHONPATH",[231,311,272],{"class":264},[231,313,314],{"class":268},"\u002Fusr\u002Fshare\u002Fqgis\u002Fpython:${PYTHONPATH",[231,316,317],{"class":264},":-",[231,319,320],{"class":268},"}\n",[231,322,324,326,329,331],{"class":182,"line":323},8,[231,325,265],{"class":264},[231,327,328],{"class":268}," QGIS_CUSTOM_CONFIG_PATH",[231,330,272],{"class":264},[231,332,333],{"class":268},"\u002Fsrv\u002Fgis\u002Fprofile\n",[231,335,337,339,342,344],{"class":182,"line":336},9,[231,338,265],{"class":264},[231,340,341],{"class":268}," QGIS_AUTH_PASSWORD_FILE",[231,343,272],{"class":264},[231,345,346],{"class":268},"\u002Fsrv\u002Fgis\u002Fsecrets\u002Fmaster\n",[231,348,350],{"class":182,"line":349},10,[231,351,258],{"emptyLinePlaceholder":257},[231,353,355,358],{"class":182,"line":354},11,[231,356,357],{"class":243},"cd",[231,359,360],{"class":250}," \u002Fsrv\u002Fgis\u002Fjobs\n",[231,362,364],{"class":182,"line":363},12,[231,365,258],{"emptyLinePlaceholder":257},[231,367,369,372,375,378,381,384,387],{"class":182,"line":368},13,[231,370,371],{"class":243},"exec",[231,373,374],{"class":250}," \u002Fusr\u002Fbin\u002Fpython3",[231,376,377],{"class":250}," nightly_export.py",[231,379,380],{"class":243}," --source",[231,382,383],{"class":250}," \u002Fdata\u002Freadings.gpkg",[231,385,386],{"class":243}," --out",[231,388,389],{"class":250}," \u002Fsrv\u002Fgis\u002Foutput\n",[14,391,392,396,397,400,401,403,404,407,408,411,412,414,415,417],{},[393,394,395],"strong",{},"Breakdown:"," ",[18,398,399],{},"set -euo pipefail"," makes the wrapper stop on the first error rather than carrying on with a broken environment. Every variable is set explicitly because cron inherits almost nothing: ",[18,402,105],{}," is what makes ",[18,405,406],{},"import qgis.core"," work outside the QGIS launcher, and ",[18,409,410],{},"QGIS_CUSTOM_CONFIG_PATH"," selects the profile whose plugins, saved connections and authentication database the job needs. ",[18,413,371],{}," replaces the shell with Python so signals reach the script directly — which matters when a scheduler kills a run. Everything uses absolute paths; the ",[18,416,357],{}," exists only for code that writes relative temporary files.",[14,419,420,421,424,425,428],{},"Save it as ",[18,422,423],{},"\u002Fsrv\u002Fgis\u002Fjobs\u002Fnightly_export.sh",", ",[18,426,427],{},"chmod +x"," it, and — the important step — run it by hand once. A wrapper that has never been executed manually is a wrapper with an untested typo.",[194,430,432],{"id":431},"the-crontab-entry","The crontab entry",[222,434,436],{"className":224,"code":435,"language":226,"meta":227,"style":227},"# m  h  dom mon dow  command\n  15 2  *   *   *    \u002Fsrv\u002Fgis\u002Fjobs\u002Fnightly_export.sh >> \u002Fvar\u002Flog\u002Fgis\u002Fnightly.log 2>&1\n",[18,437,438,443],{"__ignoreMap":227},[231,439,440],{"class":182,"line":233},[231,441,442],{"class":236},"# m  h  dom mon dow  command\n",[231,444,445,449,452,455,458,460,463,466,469],{"class":182,"line":240},[231,446,448],{"class":447},"svObZ","  15",[231,450,451],{"class":243}," 2",[231,453,454],{"class":243},"  *",[231,456,457],{"class":243},"   *",[231,459,457],{"class":243},[231,461,462],{"class":250},"    \u002Fsrv\u002Fgis\u002Fjobs\u002Fnightly_export.sh",[231,464,465],{"class":264}," >>",[231,467,468],{"class":250}," \u002Fvar\u002Flog\u002Fgis\u002Fnightly.log",[231,470,471],{"class":264}," 2>&1\n",[14,473,474,396,476,479,480,483,484,487,488,491],{},[393,475,395],{},[18,477,478],{},"2:15"," rather than ",[18,481,482],{},"2:00"," is a small kindness: the top of the hour is when every other job on the machine starts. ",[18,485,486],{},">>"," appends so the log accumulates rather than being truncated each night, and ",[18,489,490],{},"2>&1"," captures standard error into the same file — without it, the traceback from a failure goes to cron's mail, which on most servers goes nowhere. One line, one script; anything more complex belongs inside the wrapper where it can be tested.",[194,493,495],{"id":494},"stop-runs-from-overlapping","Stop runs from overlapping",[14,497,498],{},"A job that normally takes four minutes will one day take ninety. If it runs hourly, you now have two copies writing the same output.",[222,500,502],{"className":224,"code":501,"language":226,"meta":227,"style":227},"exec flock -n \u002Fvar\u002Flock\u002Fgis-nightly.lock \\\n  \u002Fusr\u002Fbin\u002Fpython3 nightly_export.py --source \u002Fdata\u002Freadings.gpkg --out \u002Fsrv\u002Fgis\u002Foutput\n",[18,503,504,520],{"__ignoreMap":227},[231,505,506,508,511,514,517],{"class":182,"line":233},[231,507,371],{"class":243},[231,509,510],{"class":250}," flock",[231,512,513],{"class":243}," -n",[231,515,516],{"class":250}," \u002Fvar\u002Flock\u002Fgis-nightly.lock",[231,518,519],{"class":243}," \\\n",[231,521,522,525,527,529,531,533],{"class":182,"line":240},[231,523,524],{"class":250},"  \u002Fusr\u002Fbin\u002Fpython3",[231,526,377],{"class":250},[231,528,380],{"class":243},[231,530,383],{"class":250},[231,532,386],{"class":243},[231,534,389],{"class":250},[14,536,537,396,539,542,543,546],{},[393,538,395],{},[18,540,541],{},"flock -n"," takes an exclusive lock on the file and, because of ",[18,544,545],{},"-n",", gives up immediately rather than queueing when another run already holds it. The second invocation exits with status 1 and writes nothing — no duplicate rows, no half-written GeoPackage. The lock is released automatically when the process ends, including when it is killed, because it lives on the file descriptor rather than in the file's contents.",[14,548,549],{},[34,550,553,556,559,562,565,571,577,582,586,590,595,598,604,606,609,613,619,624,628,632,636,640,643],{"viewBox":551,"role":37,"ariaLabel":552,"xmlns":39},"0 0 760 250","Timeline of hourly runs where one run overruns its hour, comparing an unlocked schedule where two runs overlap with a locked schedule where the second run exits immediately",[41,554,555],{},"What a lock prevents when a run overruns",[45,557,558],{},"Two timelines cover three hours. Without a lock, the run starting at two o'clock is still going when the three o'clock run starts, and the overlapping region is marked as two processes writing the same output. With a lock, the three o'clock run exits immediately and the four o'clock run proceeds normally once the long run has finished.",[49,560],{"x":51,"y":51,"width":52,"height":561,"fill":54},"250",[73,563,564],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"One slow night is all it takes",[73,566,570],{"x":567,"y":568,"style":569,"fill":126},"60","80","font-size:12px;font-weight:bold;font-family:sans-serif","no lock",[49,572],{"x":185,"y":573,"width":574,"height":76,"rx":575,"fill":125,"stroke":576,"style":90},"62","300","4","#b45309",[73,578,581],{"x":574,"y":579,"style":580,"fill":71,"textAnchor":79},"81","text-anchor:middle;font-size:10px;font-family:sans-serif","02:00 run — overruns",[49,583],{"x":584,"y":585,"width":574,"height":76,"rx":575,"fill":125,"stroke":576,"style":90},"370","96",[73,587,589],{"x":155,"y":588,"style":580,"fill":71,"textAnchor":79},"115","03:00 run — starts anyway",[182,591],{"x1":584,"y1":592,"x2":584,"y2":593,"stroke":126,"style":594},"56","130","stroke-width:2;stroke-dasharray:4 3",[182,596],{"x1":597,"y1":592,"x2":597,"y2":593,"stroke":126,"style":594},"450",[73,599,603],{"x":600,"y":601,"style":602,"fill":126,"textAnchor":79},"410","50","text-anchor:middle;font-size:10px;font-weight:bold;font-family:sans-serif","both writing",[73,605,541],{"x":567,"y":143,"style":569,"fill":89},[49,607],{"x":185,"y":608,"width":574,"height":76,"rx":575,"fill":88,"stroke":89,"style":90},"154",[73,610,612],{"x":574,"y":611,"style":580,"fill":71,"textAnchor":79},"173","02:00 run — holds the lock",[49,614],{"x":584,"y":615,"width":616,"height":76,"rx":575,"fill":617,"stroke":191,"style":618},"188","86","#fffdf7","stroke-width:1.5",[73,620,623],{"x":621,"y":622,"style":580,"fill":71,"textAnchor":79},"413","207","03:00 skipped",[49,625],{"x":626,"y":615,"width":627,"height":76,"rx":575,"fill":88,"stroke":89,"style":90},"470","200",[73,629,631],{"x":630,"y":622,"style":580,"fill":71,"textAnchor":79},"570","04:00 run — normal",[182,633],{"x1":185,"y1":634,"x2":635,"y2":634,"stroke":191,"style":618},"232","710",[73,637,639],{"x":185,"y":638,"style":580,"fill":191,"textAnchor":79},"246","02:00",[73,641,642],{"x":584,"y":638,"style":580,"fill":191,"textAnchor":79},"03:00",[73,644,645],{"x":630,"y":638,"style":580,"fill":191,"textAnchor":79},"04:00",[194,647,649],{"id":648},"make-a-failure-reach-a-person","Make a failure reach a person",[14,651,652],{},"A log file nobody reads is not monitoring. The minimum useful arrangement is a wrapper that notices a non-zero exit and does something about it:",[222,654,656],{"className":224,"code":655,"language":226,"meta":227,"style":227},"run_job() {\n  \u002Fusr\u002Fbin\u002Fpython3 nightly_export.py --source \"$SOURCE\" --out \"$OUT\"\n}\n\nif ! run_job; then\n  status=$?\n  tail -n 40 \u002Fvar\u002Flog\u002Fgis\u002Fnightly.log \\\n    | mail -s \"nightly export FAILED (exit $status) on $(hostname)\" gis-team@example.org\n  exit \"$status\"\nfi\n",[18,657,658,666,693,697,701,718,728,742,771,782],{"__ignoreMap":227},[231,659,660,663],{"class":182,"line":233},[231,661,662],{"class":447},"run_job",[231,664,665],{"class":268},"() {\n",[231,667,668,670,672,674,677,680,683,685,687,690],{"class":182,"line":240},[231,669,524],{"class":447},[231,671,377],{"class":250},[231,673,380],{"class":243},[231,675,676],{"class":250}," \"",[231,678,679],{"class":268},"$SOURCE",[231,681,682],{"class":250},"\"",[231,684,386],{"class":243},[231,686,676],{"class":250},[231,688,689],{"class":268},"$OUT",[231,691,692],{"class":250},"\"\n",[231,694,695],{"class":182,"line":254},[231,696,320],{"class":268},[231,698,699],{"class":182,"line":261},[231,700,258],{"emptyLinePlaceholder":257},[231,702,703,706,709,712,715],{"class":182,"line":278},[231,704,705],{"class":264},"if",[231,707,708],{"class":264}," !",[231,710,711],{"class":447}," run_job",[231,713,714],{"class":268},"; ",[231,716,717],{"class":264},"then\n",[231,719,720,723,725],{"class":182,"line":291},[231,721,722],{"class":268},"  status",[231,724,272],{"class":264},[231,726,727],{"class":243},"$?\n",[231,729,730,733,735,738,740],{"class":182,"line":304},[231,731,732],{"class":447},"  tail",[231,734,513],{"class":243},[231,736,737],{"class":243}," 40",[231,739,468],{"class":250},[231,741,519],{"class":243},[231,743,744,747,750,753,756,759,762,765,768],{"class":182,"line":323},[231,745,746],{"class":264},"    |",[231,748,749],{"class":447}," mail",[231,751,752],{"class":243}," -s",[231,754,755],{"class":250}," \"nightly export FAILED (exit ",[231,757,758],{"class":268},"$status",[231,760,761],{"class":250},") on $(",[231,763,764],{"class":447},"hostname",[231,766,767],{"class":250},")\"",[231,769,770],{"class":250}," gis-team@example.org\n",[231,772,773,776,778,780],{"class":182,"line":336},[231,774,775],{"class":243},"  exit",[231,777,676],{"class":250},[231,779,758],{"class":268},[231,781,692],{"class":250},[231,783,784],{"class":182,"line":349},[231,785,786],{"class":264},"fi\n",[14,788,789,791,792,795,796,800,801,207],{},[393,790,395],{}," The tail of the log goes into the message, so the recipient sees the traceback rather than being told to go and look for it. Preserving the exit status matters if the wrapper is itself called by something else. The same shape works with any notification channel — a webhook, a systemd ",[18,793,794],{},"OnFailure="," unit, a monitoring agent — and the useful discipline is that the ",[797,798,799],"em",{},"absence"," of a nightly success is also worth alerting on, since a job that never started produces no failure at all. The complementary logging patterns are in ",[26,802,804],{"href":803},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fhandle-errors-and-logging-in-unattended-scripts\u002F","Handle Errors and Logging in Unattended Scripts",[194,806,808],{"id":807},"the-systemd-timer-equivalent","The systemd timer equivalent",[14,810,811],{},"On a modern Linux server, a timer does everything the crontab line does and several things it cannot. The wrapper script is unchanged; only the declaration differs.",[222,813,817],{"className":814,"code":815,"language":816,"meta":227,"style":227},"language-ini shiki shiki-themes github-dark","# \u002Fetc\u002Fsystemd\u002Fsystem\u002Fgis-nightly.service\n[Unit]\nDescription=Nightly GIS export\nAfter=network-online.target\n\n[Service]\nType=oneshot\nUser=gis\nExecStart=\u002Fsrv\u002Fgis\u002Fjobs\u002Fnightly_export.sh\nTimeoutStartSec=3600\nOnFailure=gis-alert@%n.service\n","ini",[18,818,819,824,829,834,839,843,848,853,858,863,868],{"__ignoreMap":227},[231,820,821],{"class":182,"line":233},[231,822,823],{},"# \u002Fetc\u002Fsystemd\u002Fsystem\u002Fgis-nightly.service\n",[231,825,826],{"class":182,"line":240},[231,827,828],{},"[Unit]\n",[231,830,831],{"class":182,"line":254},[231,832,833],{},"Description=Nightly GIS export\n",[231,835,836],{"class":182,"line":261},[231,837,838],{},"After=network-online.target\n",[231,840,841],{"class":182,"line":278},[231,842,258],{"emptyLinePlaceholder":257},[231,844,845],{"class":182,"line":291},[231,846,847],{},"[Service]\n",[231,849,850],{"class":182,"line":304},[231,851,852],{},"Type=oneshot\n",[231,854,855],{"class":182,"line":323},[231,856,857],{},"User=gis\n",[231,859,860],{"class":182,"line":336},[231,861,862],{},"ExecStart=\u002Fsrv\u002Fgis\u002Fjobs\u002Fnightly_export.sh\n",[231,864,865],{"class":182,"line":349},[231,866,867],{},"TimeoutStartSec=3600\n",[231,869,870],{"class":182,"line":354},[231,871,872],{},"OnFailure=gis-alert@%n.service\n",[222,874,876],{"className":814,"code":875,"language":816,"meta":227,"style":227},"# \u002Fetc\u002Fsystemd\u002Fsystem\u002Fgis-nightly.timer\n[Unit]\nDescription=Run the nightly GIS export\n\n[Timer]\nOnCalendar=*-*-* 02:15:00\nRandomizedDelaySec=300\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",[18,877,878,883,887,892,896,901,906,911,916,920,925],{"__ignoreMap":227},[231,879,880],{"class":182,"line":233},[231,881,882],{},"# \u002Fetc\u002Fsystemd\u002Fsystem\u002Fgis-nightly.timer\n",[231,884,885],{"class":182,"line":240},[231,886,828],{},[231,888,889],{"class":182,"line":254},[231,890,891],{},"Description=Run the nightly GIS export\n",[231,893,894],{"class":182,"line":261},[231,895,258],{"emptyLinePlaceholder":257},[231,897,898],{"class":182,"line":278},[231,899,900],{},"[Timer]\n",[231,902,903],{"class":182,"line":291},[231,904,905],{},"OnCalendar=*-*-* 02:15:00\n",[231,907,908],{"class":182,"line":304},[231,909,910],{},"RandomizedDelaySec=300\n",[231,912,913],{"class":182,"line":323},[231,914,915],{},"Persistent=true\n",[231,917,918],{"class":182,"line":336},[231,919,258],{"emptyLinePlaceholder":257},[231,921,922],{"class":182,"line":349},[231,923,924],{},"[Install]\n",[231,926,927],{"class":182,"line":354},[231,928,929],{},"WantedBy=timers.target\n",[14,931,932,396,934,937,938,941,942,945,946,948,949,952,953,956],{},[393,933,395],{},[18,935,936],{},"After=network-online.target"," waits for the network, which removes the classic failure of a job starting before a database is reachable after a reboot. ",[18,939,940],{},"TimeoutStartSec"," kills a run that has hung, converting an indefinite stall into a recorded failure — the guarantee ",[18,943,944],{},"flock"," alone does not give you. ",[18,947,794],{}," launches a separate alerting unit whenever the job exits non-zero, so notification is configuration rather than code inside the script. ",[18,950,951],{},"Persistent=true"," runs a missed occurrence once the machine comes back, which matters for a nightly job on a server that is not always on, and ",[18,954,955],{},"RandomizedDelaySec"," spreads load when several timers share a start time.",[14,958,959,960,963,964,967,968,971],{},"Two operational conveniences follow for free: ",[18,961,962],{},"systemctl start gis-nightly.service"," runs the job by hand exactly as the timer will run it, and ",[18,965,966],{},"journalctl -u gis-nightly"," gives timestamped, rotated logs with no logrotate configuration. ",[18,969,970],{},"systemd-analyze calendar \"*-*-* 02:15:00\""," prints the next few firing times, which is a better way to check a schedule than waiting to see whether it fires.",[14,973,974],{},[34,975,978,981,984,987,991,997,1003,1006,1010,1014,1018,1021,1026,1029,1031,1034,1037,1040,1043,1046,1049,1051,1053,1056,1060,1063,1065,1068,1072,1074],{"viewBox":976,"role":37,"ariaLabel":977,"xmlns":39},"0 0 760 240","Feature comparison between a cron entry and a systemd timer covering scheduling, dependency ordering, hang detection, failure handling, missed runs and logging",[41,979,980],{},"What a timer adds over a crontab line",[45,982,983],{},"Both cron and systemd timers schedule a job. Only the timer waits for the network, kills a hung run after a timeout, launches an alerting unit on failure, runs a missed occurrence after downtime, and records timestamped logs in the journal without extra configuration.",[49,985],{"x":51,"y":51,"width":52,"height":986,"fill":54},"240",[73,988,990],{"x":75,"y":989,"style":77,"fill":78,"textAnchor":79},"26","The wrapper stays the same; the declaration gains guarantees",[49,992],{"x":83,"y":993,"width":994,"height":76,"rx":995,"fill":996,"stroke":191,"style":618},"42","404","6","#efeadd",[73,998,1002],{"x":999,"y":1000,"style":1001,"fill":78},"30","61","font-size:11px;font-weight:bold;font-family:sans-serif","capability",[49,1004],{"x":1005,"y":993,"width":108,"height":76,"rx":995,"fill":125,"stroke":576,"style":618},"428",[73,1007,215],{"x":1008,"y":1000,"style":1009,"fill":576,"textAnchor":79},"501","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif",[49,1011],{"x":1012,"y":993,"width":1013,"height":76,"rx":995,"fill":88,"stroke":89,"style":618},"582","162",[73,1015,1017],{"x":1016,"y":1000,"style":1009,"fill":89,"textAnchor":79},"663","systemd timer",[49,1019],{"x":83,"y":1020,"width":994,"height":989,"rx":64,"fill":617,"stroke":191,"style":618},"76",[73,1022,1025],{"x":999,"y":1023,"style":1024,"fill":71},"94","font-size:11px;font-family:sans-serif","runs on a schedule",[73,1027,1028],{"x":1008,"y":1023,"style":100,"fill":89,"textAnchor":79},"yes",[73,1030,1028],{"x":1016,"y":1023,"style":100,"fill":89,"textAnchor":79},[49,1032],{"x":83,"y":1033,"width":994,"height":989,"rx":64,"fill":617,"stroke":191,"style":618},"106",[73,1035,1036],{"x":999,"y":104,"style":1024,"fill":71},"waits for the network to be up",[73,1038,1039],{"x":1008,"y":104,"style":100,"fill":126,"textAnchor":79},"no",[73,1041,1042],{"x":1016,"y":104,"style":100,"fill":89,"textAnchor":79},"After=",[49,1044],{"x":83,"y":1045,"width":994,"height":989,"rx":64,"fill":617,"stroke":191,"style":618},"136",[73,1047,1048],{"x":999,"y":608,"style":1024,"fill":71},"kills a run that hangs",[73,1050,1039],{"x":1008,"y":608,"style":100,"fill":126,"textAnchor":79},[73,1052,940],{"x":1016,"y":608,"style":100,"fill":89,"textAnchor":79},[49,1054],{"x":83,"y":1055,"width":994,"height":989,"rx":64,"fill":617,"stroke":191,"style":618},"166",[73,1057,1059],{"x":999,"y":1058,"style":1024,"fill":71},"184","alerts on a non-zero exit",[73,1061,1062],{"x":1008,"y":1058,"style":100,"fill":576,"textAnchor":79},"in the script",[73,1064,794],{"x":1016,"y":1058,"style":100,"fill":89,"textAnchor":79},[49,1066],{"x":83,"y":1067,"width":994,"height":989,"rx":64,"fill":617,"stroke":191,"style":618},"196",[73,1069,1071],{"x":999,"y":1070,"style":1024,"fill":71},"214","runs a run missed during downtime",[73,1073,1039],{"x":1008,"y":1070,"style":100,"fill":126,"textAnchor":79},[73,1075,951],{"x":1016,"y":1070,"style":100,"fill":89,"textAnchor":79},[194,1077,1079],{"id":1078},"qgis-version-compatibility","QGIS version compatibility",[14,1081,1082],{},"The environment variables shown are stable across the whole QGIS 3.x series.",[1084,1085,1086,1102],"table",{},[1087,1088,1089],"thead",{},[1090,1091,1092,1096,1099],"tr",{},[1093,1094,1095],"th",{},"QGIS version",[1093,1097,1098],{},"Python",[1093,1100,1101],{},"Notes",[1103,1104,1105,1123,1133,1151],"tbody",{},[1090,1106,1107,1111,1114],{},[1108,1109,1110],"td",{},"3.22 LTR",[1108,1112,1113],{},"3.9",[1108,1115,1116,1118,1119,1122],{},[18,1117,410],{}," and ",[18,1120,1121],{},"QGIS_AUTH_PASSWORD_FILE"," behave as shown.",[1090,1124,1125,1128,1130],{},[1108,1126,1127],{},"3.28 LTR",[1108,1129,1113],{},[1108,1131,1132],{},"Identical.",[1090,1134,1135,1138,1141],{},[1108,1136,1137],{},"3.34 LTR",[1108,1139,1140],{},"3.12",[1108,1142,1143,1144,1146,1147,1150],{},"Baseline for this page; ",[18,1145,105],{}," is ",[18,1148,1149],{},"\u002Fusr\u002Fshare\u002Fqgis\u002Fpython"," on Debian packages.",[1090,1152,1153,1156,1158],{},[1108,1154,1155],{},"3.40 \u002F 3.44",[1108,1157,1140],{},[1108,1159,1160],{},"Unchanged. Confirm the Python path after an upgrade, since it follows the packaged interpreter.",[194,1162,1164],{"id":1163},"troubleshooting","Troubleshooting",[199,1166,1167,1179,1192,1200,1210,1216],{},[202,1168,1169,396,1172,1174,1175,1178],{},[393,1170,1171],{},"\"No module named qgis\".",[18,1173,105],{}," does not include the QGIS Python directory. Find it with ",[18,1176,1177],{},"python3 -c \"import qgis, os; print(os.path.dirname(qgis.__file__))\""," in a working shell.",[202,1180,1181,1184,1185,1188,1189,207],{},[393,1182,1183],{},"The job works by hand and not from cron."," Compare environments: add ",[18,1186,1187],{},"env > \u002Ftmp\u002Fcron-env.txt"," as a temporary first line in the wrapper and diff it against your shell's ",[18,1190,1191],{},"env",[202,1193,1194,1197,1198,207],{},[393,1195,1196],{},"It hangs forever."," Something wants a display or a password. Set the offscreen platform and provide ",[18,1199,1121],{},[202,1201,1202,1205,1206,1209],{},[393,1203,1204],{},"The log is empty even though the script prints."," Python is buffering. Set ",[18,1207,1208],{},"PYTHONUNBUFFERED=1"," in the wrapper.",[202,1211,1212,1215],{},[393,1213,1214],{},"Output lands in the wrong place."," A relative path resolved against cron's working directory. Make every path absolute.",[202,1217,1218,1221,1222,1225,1226,1229],{},[393,1219,1220],{},"A percent sign in the crontab line breaks it."," cron treats ",[18,1223,1224],{},"%"," as a newline; escape it as ",[18,1227,1228],{},"\\%",". This bites hardest when a date format is embedded in the command — another argument for keeping the crontab line trivial.",[194,1231,1233],{"id":1232},"conclusion","Conclusion",[14,1235,1236],{},"Scheduling a PyQGIS script is mostly about restoring what cron removes. Put the environment in a wrapper script you can run by hand, keep the crontab line to one command plus a redirect, take a lock so a slow run cannot overlap the next, and make a non-zero exit reach a human. The Python is the part that already works.",[194,1238,1240],{"id":1239},"frequently-asked-questions","Frequently Asked Questions",[14,1242,1243,1246,1247,1249],{},[393,1244,1245],{},"Should I use systemd timers instead?","\nOn a modern Linux server, yes — timers give you dependency ordering, resource limits, ",[18,1248,794],{}," handlers and journald logging. The wrapper script is identical; only the scheduling declaration changes.",[14,1251,1252,1255,1256,1259],{},[393,1253,1254],{},"How do I schedule this on Windows?","\nTask Scheduler, calling a ",[18,1257,1258],{},".bat"," wrapper that sets the same variables through the OSGeo4W environment script. Run the task whether or not the user is logged on, and give it a working directory explicitly.",[14,1261,1262,1265,1266,1269,1270,207],{},[393,1263,1264],{},"Can cron run a Docker container?","\nYes, and it is a good pairing: the container fixes the environment, so the wrapper shrinks to a ",[18,1267,1268],{},"docker run"," with mounts. See ",[26,1271,1273],{"href":1272},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container\u002F","Run PyQGIS in a Docker Container",[14,1275,1276,1279],{},[393,1277,1278],{},"How do I stop the log growing without limit?","\nAdd a logrotate rule for the file. Rotating daily and keeping fourteen copies is a sensible default for a nightly job.",[14,1281,1282,1285,1286,1289],{},[393,1283,1284],{},"Where should the wrapper and the job files live?","\nIn a directory owned by the job's user and under version control — ",[18,1287,1288],{},"\u002Fsrv\u002Fgis\u002Fjobs"," in these examples. Keeping the wrapper, the Python script and any SQL together means a change is reviewable and a rollback is a checkout rather than an act of memory.",[14,1291,1292,1295,1296,1299],{},[393,1293,1294],{},"What if the script needs to run only on weekdays?","\nUse the day-of-week field: ",[18,1297,1298],{},"15 2 * * 1-5",". Resist encoding business rules such as public holidays there — put them in the script, where they can be tested.",[194,1301,1303],{"id":1302},"related","Related",[199,1305,1306,1311,1315,1319,1325],{},[202,1307,1308,1310],{},[26,1309,29],{"href":28}," — the guide this recipe belongs to",[202,1312,1313],{},[26,1314,804],{"href":803},[202,1316,1317],{},[26,1318,1273],{"href":1272},[202,1320,1321],{},[26,1322,1324],{"href":1323},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002Frunning-python-scripts-outside-qgis-desktop\u002F","Running Python Scripts Outside QGIS Desktop",[202,1326,1327],{},[26,1328,1330],{"href":1329},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002F","Batch Processing with PyQGIS",[1332,1333,1334],"style",{},"html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}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 pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":227,"searchDepth":240,"depth":240,"links":1336},[1337,1338,1339,1340,1341,1342,1343,1344,1345,1346,1347],{"id":196,"depth":240,"text":197},{"id":219,"depth":240,"text":220},{"id":431,"depth":240,"text":432},{"id":494,"depth":240,"text":495},{"id":648,"depth":240,"text":649},{"id":807,"depth":240,"text":808},{"id":1078,"depth":240,"text":1079},{"id":1163,"depth":240,"text":1164},{"id":1232,"depth":240,"text":1233},{"id":1239,"depth":240,"text":1240},{"id":1302,"depth":240,"text":1303},"Run a PyQGIS script on a schedule — supply the environment cron does not give you, use a wrapper script, prevent overlapping runs, and make sure failures are noticed.","md",{"slug":12,"type":1351,"breadcrumb":1352,"datePublished":1353,"dateModified":1353},"article","Schedule with cron","2026-08-10","\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron",{"title":5,"description":1348},"pyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002Findex","lDf5_gwQzxWV80Sn_LQVGesyZmR71AFqHpwdgB4Qo28",1786401337551]