[{"data":1,"prerenderedAt":1906},["ShallowReactive",2],{"doc:\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation":3},{"id":4,"title":5,"body":6,"description":1896,"extension":1897,"meta":1898,"navigation":240,"path":1902,"seo":1903,"stem":1904,"__hash__":1905},"docs\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Findex.md","Headless QGIS and Server Automation",{"type":7,"value":8,"toc":1881},"minimark",[9,13,17,31,153,158,186,190,193,356,403,411,415,422,538,590,616,620,623,636,642,652,663,667,670,838,862,878,882,885,892,977,981,984,1473,1507,1511,1514,1532,1542,1552,1615,1620,1624,1630,1641,1645,1648,1658,1668,1680,1686,1690,1744,1748,1758,1768,1774,1780,1790,1796,1806,1812,1818,1827,1831,1877],[10,11,5],"h1",{"id":12},"headless-qgis-and-server-automation",[14,15,16],"p",{},"The script that works beautifully in the Python console has one dependency it never told you about: a person. Someone opened QGIS, loaded a project, and clicked run. Moving that script onto a server means removing the person — and with them the display, the interactive prompts, the assumption that the current project is loaded, and the habit of noticing when something looks wrong.",[14,18,19,20,25,26,30],{},"This guide sits inside ",[21,22,24],"a",{"href":23},"\u002Fpyqgis-fundamentals-environment-setup\u002F","PyQGIS Fundamentals & Environment Setup"," and covers everything between \"my script works\" and \"my script runs every night without me\". It builds on ",[21,27,29],{"href":28},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002Frunning-python-scripts-outside-qgis-desktop\u002F","Running Python Scripts Outside QGIS Desktop",", which explains how to initialise QGIS from a plain Python process; here we take that starting point onto a machine with no screen and keep it running reliably.",[14,32,33],{},[34,35,40,44,48,55,64,74,81,86,90,95,99,105,110,117,122,127,131,135,137,141,145,149],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 300","img","Layered view of a headless QGIS run: operating system with no display, an offscreen QGIS application, the processing registry, the script, and the outputs and logs it produces","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"What a headless QGIS process is made of",[45,46,47],"desc",{},"From the bottom up: a server with no display server running, then QGIS initialised with the offscreen platform, then the processing registry with native and third-party providers loaded, then your script, and at the top the outputs it writes and the log stream a scheduler collects. A side note marks the two things that must be supplied explicitly because no desktop session provides them: the QGIS prefix path and the authentication master password.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","300","#f6f3ea",[56,57,63],"text",{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The desktop supplied five things — now you do",[49,65],{"x":66,"y":67,"width":68,"height":69,"rx":70,"fill":71,"stroke":72,"style":73},"40","228","440","46","8","#fffdf7","#59645f","stroke-width:2",[56,75,80],{"x":76,"y":77,"style":78,"fill":79,"textAnchor":62},"260","256","text-anchor:middle;font-size:12px;font-family:sans-serif","#2f3b35","server · no X display · QT_QPA_PLATFORM=offscreen",[49,82],{"x":66,"y":83,"width":68,"height":69,"rx":70,"fill":84,"stroke":85,"style":73},"176","#eef7f4","#0f766e",[56,87,89],{"x":76,"y":88,"style":78,"fill":79,"textAnchor":62},"204","QgsApplication — prefix path, initQgis()",[49,91],{"x":66,"y":92,"width":68,"height":69,"rx":70,"fill":93,"stroke":94,"style":73},"124","#eff3ff","#2563eb",[56,96,98],{"x":76,"y":97,"style":78,"fill":79,"textAnchor":62},"152","Processing registry — native + GDAL providers",[49,100],{"x":66,"y":101,"width":68,"height":69,"rx":70,"fill":102,"stroke":103,"style":104},"72","#edf8e9","#15803d","stroke-width:2.5",[56,106,109],{"x":76,"y":107,"style":108,"fill":103,"textAnchor":62},"100","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","your script",[49,111],{"x":112,"y":101,"width":113,"height":114,"rx":70,"fill":115,"stroke":116,"style":73},"512","216","94","#fdf2e2","#b45309",[56,118,121],{"x":119,"y":120,"style":108,"fill":116,"textAnchor":62},"620","98","must be supplied",[56,123,126],{"x":119,"y":124,"style":125,"fill":79,"textAnchor":62},"120","text-anchor:middle;font-size:11px;font-family:sans-serif","prefix path · profile dir",[56,128,130],{"x":119,"y":129,"style":125,"fill":79,"textAnchor":62},"139","auth master password",[56,132,134],{"x":119,"y":133,"style":125,"fill":79,"textAnchor":62},"158","absolute paths",[49,136],{"x":112,"y":83,"width":113,"height":120,"rx":70,"fill":71,"stroke":61,"style":73},[56,138,140],{"x":119,"y":139,"style":108,"fill":61,"textAnchor":62},"202","what comes out",[56,142,144],{"x":119,"y":143,"style":125,"fill":79,"textAnchor":62},"224","outputs on disk or in a table",[56,146,148],{"x":119,"y":147,"style":125,"fill":79,"textAnchor":62},"243","structured log lines",[56,150,152],{"x":119,"y":151,"style":125,"fill":79,"textAnchor":62},"262","an exit code that means it",[154,155,157],"h2",{"id":156},"what-you-will-learn","What you will learn",[14,159,160,161,165,166,170,171,175,176,180,181,185],{},"The four pages under this guide take one step each. ",[21,162,164],{"href":163},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fuse-qgis-process-command-line-runner\u002F","Use the qgis_process Command-Line Runner"," covers the case where no Python is needed at all. ",[21,167,169],{"href":168},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Frun-pyqgis-in-docker-container\u002F","Run PyQGIS in a Docker Container"," pins the environment so the run is reproducible. ",[21,172,174],{"href":173},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002F","Schedule PyQGIS Scripts with cron"," deals with the environment a scheduler does ",[177,178,179],"em",{},"not"," give you. ",[21,182,184],{"href":183},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fhandle-errors-and-logging-in-unattended-scripts\u002F","Handle Errors and Logging in Unattended Scripts"," is what turns a silent failure into a message someone acts on.",[154,187,189],{"id":188},"initialising-qgis-with-no-display","Initialising QGIS with no display",[14,191,192],{},"Qt needs a platform plugin even when nothing will be drawn. On a server there is no X display, so tell Qt to use the offscreen platform and QGIS never asks for one.",[194,195,200],"pre",{"className":196,"code":197,"language":198,"meta":199,"style":199},"language-python shiki shiki-themes github-dark","import os\nos.environ.setdefault(\"QT_QPA_PLATFORM\", \"offscreen\")\n\nfrom qgis.core import QgsApplication\n\nQgsApplication.setPrefixPath(\"\u002Fusr\", True)\napp = QgsApplication([], False)\napp.initQgis()\n\nfrom processing.core.Processing import Processing\nimport processing\nProcessing.initialize()\n\n# … work …\n\napp.exitQgis()\n","python","",[201,202,203,216,235,242,256,261,278,295,301,306,319,327,333,338,345,350],"code",{"__ignoreMap":199},[204,205,208,212],"span",{"class":206,"line":207},"line",1,[204,209,211],{"class":210},"snl16","import",[204,213,215],{"class":214},"s95oV"," os\n",[204,217,219,222,226,229,232],{"class":206,"line":218},2,[204,220,221],{"class":214},"os.environ.setdefault(",[204,223,225],{"class":224},"sU2Wk","\"QT_QPA_PLATFORM\"",[204,227,228],{"class":214},", ",[204,230,231],{"class":224},"\"offscreen\"",[204,233,234],{"class":214},")\n",[204,236,238],{"class":206,"line":237},3,[204,239,241],{"emptyLinePlaceholder":240},true,"\n",[204,243,245,248,251,253],{"class":206,"line":244},4,[204,246,247],{"class":210},"from",[204,249,250],{"class":214}," qgis.core ",[204,252,211],{"class":210},[204,254,255],{"class":214}," QgsApplication\n",[204,257,259],{"class":206,"line":258},5,[204,260,241],{"emptyLinePlaceholder":240},[204,262,264,267,270,272,276],{"class":206,"line":263},6,[204,265,266],{"class":214},"QgsApplication.setPrefixPath(",[204,268,269],{"class":224},"\"\u002Fusr\"",[204,271,228],{"class":214},[204,273,275],{"class":274},"sDLfK","True",[204,277,234],{"class":214},[204,279,281,284,287,290,293],{"class":206,"line":280},7,[204,282,283],{"class":214},"app ",[204,285,286],{"class":210},"=",[204,288,289],{"class":214}," QgsApplication([], ",[204,291,292],{"class":274},"False",[204,294,234],{"class":214},[204,296,298],{"class":206,"line":297},8,[204,299,300],{"class":214},"app.initQgis()\n",[204,302,304],{"class":206,"line":303},9,[204,305,241],{"emptyLinePlaceholder":240},[204,307,309,311,314,316],{"class":206,"line":308},10,[204,310,247],{"class":210},[204,312,313],{"class":214}," processing.core.Processing ",[204,315,211],{"class":210},[204,317,318],{"class":214}," Processing\n",[204,320,322,324],{"class":206,"line":321},11,[204,323,211],{"class":210},[204,325,326],{"class":214}," processing\n",[204,328,330],{"class":206,"line":329},12,[204,331,332],{"class":214},"Processing.initialize()\n",[204,334,336],{"class":206,"line":335},13,[204,337,241],{"emptyLinePlaceholder":240},[204,339,341],{"class":206,"line":340},14,[204,342,344],{"class":343},"sjoCn","# … work …\n",[204,346,348],{"class":206,"line":347},15,[204,349,241],{"emptyLinePlaceholder":240},[204,351,353],{"class":206,"line":352},16,[204,354,355],{"class":214},"app.exitQgis()\n",[14,357,358,362,363,366,367,370,371,374,375,378,379,382,383,386,387,390,391,394,395,398,399,402],{},[359,360,361],"strong",{},"Breakdown:"," The environment variable must be set ",[359,364,365],{},"before"," any Qt class is imported, which is why it comes before the ",[201,368,369],{},"qgis.core"," import. ",[201,372,373],{},"QgsApplication([], False)"," creates the application without a GUI — the second argument is what makes it headless. ",[201,376,377],{},"setPrefixPath()"," tells QGIS where its resources live; ",[201,380,381],{},"\u002Fusr"," is right for a Debian or Ubuntu package, ",[201,384,385],{},"\u002Fusr\u002Flocal"," for a source build, and on Windows it is the QGIS installation directory. ",[201,388,389],{},"Processing.initialize()"," is separate from ",[201,392,393],{},"initQgis()"," and easy to forget; without it, ",[201,396,397],{},"processing.run()"," raises \"algorithm not found\" for every native algorithm. ",[201,400,401],{},"exitQgis()"," releases the providers cleanly, which matters in a long-lived process and not much in a one-shot script.",[14,404,405,406,410],{},"Rendering works under the offscreen platform too, so map image export runs on a headless box exactly as it does on a desktop — see ",[21,407,409],{"href":408},"\u002Fpyqgis-cartography-visualization\u002Fmap-canvas-and-image-export\u002Frender-layer-to-image-without-gui-pyqgis\u002F","Render a Layer to an Image Without the GUI",".",[154,412,414],{"id":413},"script-or-command-line-runner","Script or command-line runner",[14,416,417,418,421],{},"Not every automation needs Python. QGIS ships ",[201,419,420],{},"qgis_process",", a command-line runner that executes any Processing algorithm directly, and for a single algorithm it removes the entire initialisation problem.",[14,423,424],{},[34,425,428,431,434,437,440,449,455,459,462,465,469,473,478,480,483,485,488,490,494,496,499,501,504,507,511,514,517,519,521,524,528,530,533,535],{"viewBox":426,"role":37,"ariaLabel":427,"xmlns":39},"0 0 760 250","Decision matrix comparing qgis_process and a Python script across single algorithm, chained algorithms, custom logic and layout export",[41,429,430],{},"Choosing between qgis_process and a Python script",[45,432,433],{},"A four-row comparison. A single algorithm is best run with qgis process. A fixed chain of algorithms can go either way, favouring a shell script for simple cases. Anything with conditional logic, per-feature work or error recovery needs a Python script. Layout and atlas export needs Python because no algorithm covers it.",[49,435],{"x":51,"y":51,"width":52,"height":436,"fill":54},"250",[56,438,439],{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"Reach for Python when the logic stops being a straight line",[49,441],{"x":442,"y":443,"width":444,"height":445,"rx":446,"fill":447,"stroke":72,"style":448},"16","44","336","30","6","#efeadd","stroke-width:1.5",[56,450,454],{"x":451,"y":452,"style":453,"fill":61,"textAnchor":62},"184","64","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","the task",[49,456],{"x":457,"y":443,"width":458,"height":445,"rx":446,"fill":93,"stroke":94,"style":448},"360","188",[56,460,420],{"x":461,"y":452,"style":453,"fill":94,"textAnchor":62},"454",[49,463],{"x":464,"y":443,"width":458,"height":445,"rx":446,"fill":102,"stroke":103,"style":448},"556",[56,466,468],{"x":467,"y":452,"style":453,"fill":103,"textAnchor":62},"650","Python script",[49,470],{"x":442,"y":471,"width":444,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},"80","38",[56,474,477],{"x":445,"y":475,"style":476,"fill":79},"104","font-size:11px;font-family:sans-serif","one algorithm, fixed parameters",[49,479],{"x":457,"y":471,"width":458,"height":472,"rx":446,"fill":102,"stroke":103,"style":73},[56,481,482],{"x":461,"y":475,"style":125,"fill":103,"textAnchor":62},"best choice",[49,484],{"x":464,"y":471,"width":458,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},[56,486,487],{"x":467,"y":475,"style":125,"fill":79,"textAnchor":62},"works, more setup",[49,489],{"x":442,"y":92,"width":444,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},[56,491,493],{"x":445,"y":492,"style":476,"fill":79},"148","a fixed chain of algorithms",[49,495],{"x":457,"y":92,"width":458,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},[56,497,498],{"x":461,"y":492,"style":125,"fill":79,"textAnchor":62},"fine in a shell script",[49,500],{"x":464,"y":92,"width":458,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},[56,502,503],{"x":467,"y":492,"style":125,"fill":79,"textAnchor":62},"easier to debug",[49,505],{"x":442,"y":506,"width":444,"height":472,"rx":446,"fill":71,"stroke":72,"style":448},"168",[56,508,510],{"x":445,"y":509,"style":476,"fill":79},"192","branching, retries, per-feature work",[49,512],{"x":457,"y":506,"width":458,"height":472,"rx":446,"fill":115,"stroke":513,"style":448},"#b91c1c",[56,515,516],{"x":461,"y":509,"style":125,"fill":513,"textAnchor":62},"not possible",[49,518],{"x":464,"y":506,"width":458,"height":472,"rx":446,"fill":102,"stroke":103,"style":73},[56,520,482],{"x":467,"y":509,"style":125,"fill":103,"textAnchor":62},[49,522],{"x":442,"y":523,"width":444,"height":445,"rx":446,"fill":71,"stroke":72,"style":448},"212",[56,525,527],{"x":445,"y":526,"style":476,"fill":79},"232","layout or atlas export",[49,529],{"x":457,"y":523,"width":458,"height":445,"rx":446,"fill":115,"stroke":513,"style":448},[56,531,532],{"x":461,"y":526,"style":125,"fill":513,"textAnchor":62},"no algorithm for it",[49,534],{"x":464,"y":523,"width":458,"height":445,"rx":446,"fill":102,"stroke":103,"style":73},[56,536,537],{"x":467,"y":526,"style":125,"fill":103,"textAnchor":62},"only choice",[194,539,543],{"className":540,"code":541,"language":542,"meta":199,"style":199},"language-bash shiki shiki-themes github-dark","qgis_process run native:buffer -- \\\n  INPUT=\u002Fdata\u002Froads.gpkg\\|layername=roads \\\n  DISTANCE=25 \\\n  OUTPUT=\u002Fdata\u002Foutput\u002Froads_buffer.gpkg\n","bash",[201,544,545,562,575,585],{"__ignoreMap":199},[204,546,547,550,553,556,559],{"class":206,"line":207},[204,548,420],{"class":549},"svObZ",[204,551,552],{"class":224}," run",[204,554,555],{"class":224}," native:buffer",[204,557,558],{"class":274}," --",[204,560,561],{"class":274}," \\\n",[204,563,564,567,570,573],{"class":206,"line":218},[204,565,566],{"class":224},"  INPUT=\u002Fdata\u002Froads.gpkg",[204,568,569],{"class":274},"\\|",[204,571,572],{"class":224},"layername=roads",[204,574,561],{"class":274},[204,576,577,580,583],{"class":206,"line":237},[204,578,579],{"class":224},"  DISTANCE=",[204,581,582],{"class":274},"25",[204,584,561],{"class":274},[204,586,587],{"class":206,"line":244},[204,588,589],{"class":224},"  OUTPUT=\u002Fdata\u002Foutput\u002Froads_buffer.gpkg\n",[14,591,592,594,595,598,599,602,603,605,606,609,610,613,614,410],{},[359,593,361],{}," Everything after ",[201,596,597],{},"--"," is a parameter assignment matching the algorithm's parameter names — the same names a Python call uses, which is why ",[201,600,601],{},"processing.algorithmHelp(\"native:buffer\")"," is the reference for both. The pipe in a GeoPackage data source must be escaped in a shell. ",[201,604,420],{}," exits non-zero on failure, so it composes with ",[201,607,608],{},"set -e"," and with any scheduler that watches exit codes. The full treatment, including ",[201,611,612],{},"--json"," output and running models, is in ",[21,615,164],{"href":163},[154,617,619],{"id":618},"projects-paths-and-other-desktop-assumptions","Projects, paths and other desktop assumptions",[14,621,622],{},"Three habits from interactive work break silently on a server.",[14,624,625,631,632,635],{},[359,626,627,630],{},[201,628,629],{},"QgsProject.instance()"," is empty."," No project is loaded unless you load one. Call ",[201,633,634],{},"QgsProject.instance().read(\"\u002Fsrv\u002Fprojects\u002Fflooding.qgs\")"," explicitly, and remember that a project storing relative paths resolves them against the project file, not the working directory.",[14,637,638,641],{},[359,639,640],{},"Relative paths resolve against the scheduler's working directory,"," which is rarely what you assumed. Make every path in an automated script absolute, or derive it from a configured root.",[14,643,644,647,648,651],{},[359,645,646],{},"Layer names are not unique."," ",[201,649,650],{},"mapLayersByName(\"roads\")[0]"," works fine until a project gains a second layer called \"roads\", at which point it silently starts using the wrong one. Address layers by id in unattended code.",[14,653,654,655,658,659,410],{},"Two environment concerns join them: the QGIS ",[359,656,657],{},"profile directory"," determines where plugins and the authentication database live, and a server process gets a fresh, empty one unless told otherwise. If your job connects to PostGIS with a stored credential, both the profile and the authentication master password have to be supplied — see ",[21,660,662],{"href":661},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fconnect-to-postgis-database-pyqgis\u002F","Connect to a PostGIS Database in PyQGIS",[154,664,666],{"id":665},"rendering-maps-with-no-screen","Rendering maps with no screen",[14,668,669],{},"Nothing about map rendering requires a visible window, which surprises people who assume image export is a desktop-only feature. Under the offscreen platform the same rendering engine produces the same pixels, so a nightly job can publish finished maps rather than only data.",[194,671,673],{"className":196,"code":672,"language":198,"meta":199,"style":199},"from qgis.core import QgsProject, QgsLayoutExporter\n\nproject = QgsProject.instance()\nproject.read(\"\u002Fsrv\u002Fprojects\u002Fflooding.qgz\")\n\nlayout = project.layoutManager().layoutByName(\"Overview\")\nexporter = QgsLayoutExporter(layout)\n\nsettings = QgsLayoutExporter.PdfExportSettings()\nsettings.dpi = 300\nsettings.rasterizeWholeImage = False\n\nresult = exporter.exportToPdf(\"\u002Fsrv\u002Foutput\u002Fflooding.pdf\", settings)\nif result != QgsLayoutExporter.Success:\n    raise RuntimeError(f\"layout export failed with code {result}\")\n",[201,674,675,686,690,700,710,714,729,739,743,753,763,773,777,793,807],{"__ignoreMap":199},[204,676,677,679,681,683],{"class":206,"line":207},[204,678,247],{"class":210},[204,680,250],{"class":214},[204,682,211],{"class":210},[204,684,685],{"class":214}," QgsProject, QgsLayoutExporter\n",[204,687,688],{"class":206,"line":218},[204,689,241],{"emptyLinePlaceholder":240},[204,691,692,695,697],{"class":206,"line":237},[204,693,694],{"class":214},"project ",[204,696,286],{"class":210},[204,698,699],{"class":214}," QgsProject.instance()\n",[204,701,702,705,708],{"class":206,"line":244},[204,703,704],{"class":214},"project.read(",[204,706,707],{"class":224},"\"\u002Fsrv\u002Fprojects\u002Fflooding.qgz\"",[204,709,234],{"class":214},[204,711,712],{"class":206,"line":258},[204,713,241],{"emptyLinePlaceholder":240},[204,715,716,719,721,724,727],{"class":206,"line":263},[204,717,718],{"class":214},"layout ",[204,720,286],{"class":210},[204,722,723],{"class":214}," project.layoutManager().layoutByName(",[204,725,726],{"class":224},"\"Overview\"",[204,728,234],{"class":214},[204,730,731,734,736],{"class":206,"line":280},[204,732,733],{"class":214},"exporter ",[204,735,286],{"class":210},[204,737,738],{"class":214}," QgsLayoutExporter(layout)\n",[204,740,741],{"class":206,"line":297},[204,742,241],{"emptyLinePlaceholder":240},[204,744,745,748,750],{"class":206,"line":303},[204,746,747],{"class":214},"settings ",[204,749,286],{"class":210},[204,751,752],{"class":214}," QgsLayoutExporter.PdfExportSettings()\n",[204,754,755,758,760],{"class":206,"line":308},[204,756,757],{"class":214},"settings.dpi ",[204,759,286],{"class":210},[204,761,762],{"class":274}," 300\n",[204,764,765,768,770],{"class":206,"line":321},[204,766,767],{"class":214},"settings.rasterizeWholeImage ",[204,769,286],{"class":210},[204,771,772],{"class":274}," False\n",[204,774,775],{"class":206,"line":329},[204,776,241],{"emptyLinePlaceholder":240},[204,778,779,782,784,787,790],{"class":206,"line":335},[204,780,781],{"class":214},"result ",[204,783,286],{"class":210},[204,785,786],{"class":214}," exporter.exportToPdf(",[204,788,789],{"class":224},"\"\u002Fsrv\u002Foutput\u002Fflooding.pdf\"",[204,791,792],{"class":214},", settings)\n",[204,794,795,798,801,804],{"class":206,"line":340},[204,796,797],{"class":210},"if",[204,799,800],{"class":214}," result ",[204,802,803],{"class":210},"!=",[204,805,806],{"class":214}," QgsLayoutExporter.Success:\n",[204,808,809,812,815,818,821,824,827,830,833,836],{"class":206,"line":347},[204,810,811],{"class":210},"    raise",[204,813,814],{"class":274}," RuntimeError",[204,816,817],{"class":214},"(",[204,819,820],{"class":210},"f",[204,822,823],{"class":224},"\"layout export failed with code ",[204,825,826],{"class":274},"{",[204,828,829],{"class":214},"result",[204,831,832],{"class":274},"}",[204,834,835],{"class":224},"\"",[204,837,234],{"class":214},[14,839,840,842,843,846,847,850,851,854,855,857,858,861],{},[359,841,361],{}," Reading the project explicitly is required — a headless process has no current project, and ",[201,844,845],{},"layoutByName()"," on an empty project returns ",[201,848,849],{},"None"," rather than raising, so the failure surfaces one line later as an unhelpful attribute error. ",[201,852,853],{},"rasterizeWholeImage = False"," keeps text and vectors as vectors in the PDF, which matters for print quality and file size; setting it to ",[201,856,275],{}," is the fallback when blend modes or transparency render incorrectly. The exporter returns a status code, ",[201,859,860],{},"Success"," being zero, following the same convention as the raster calculator and the file writer.",[14,863,864,865,868,869,873,874,410],{},"Two practical cautions apply to headless rendering. Fonts must be installed ",[359,866,867],{},"in the environment that renders",", not on your workstation — a missing font is silently substituted and the map comes out looking subtly wrong. And any layer whose source is a network service will be fetched at render time, so a job that renders a basemap needs network access and a timeout policy. The image-export equivalents are covered in ",[21,870,872],{"href":871},"\u002Fpyqgis-cartography-visualization\u002Fmap-canvas-and-image-export\u002F","Map Canvas Control and Image Export",", and the atlas variant in ",[21,875,877],{"href":876},"\u002Fspatial-data-processing-automation\u002Fautomating-atlas-map-series\u002F","Automating Atlas Map Series",[154,879,881],{"id":880},"making-failure-visible","Making failure visible",[14,883,884],{},"An unattended script has exactly two ways to communicate: what it writes, and the exit code it returns. Both need attention.",[14,886,887,888,891],{},"Log with structure rather than prose — a line per unit of work, with counts and durations, so a human comparing two nights can see what changed. Route QGIS's own messages into the same stream by connecting to ",[201,889,890],{},"QgsApplication.messageLog().messageReceived",", otherwise provider warnings vanish. Exit non-zero on any failure; a job that reports success after processing zero features is worse than one that crashes, because nobody investigates it.",[14,893,894],{},[34,895,898,901,904,907,910,915,920,927,932,935,939,942,946,950,953,957,960,964,966,969,971,974],{"viewBox":896,"role":37,"ariaLabel":897,"xmlns":39},"0 0 760 246","Two nightly runs compared through their logs: a silent script that reports success while writing zero features, and an instrumented script whose counts reveal the same failure immediately",[41,899,900],{},"The same failure, with and without instrumentation",[45,902,903],{},"On the left a log shows only a start line and a done line, and the run is marked successful even though nothing was written. On the right the same run logs the source count, the written count, the duration and a non-zero exit, so the drop from forty thousand features to zero is obvious at a glance.",[49,905],{"x":51,"y":51,"width":52,"height":906,"fill":54},"246",[56,908,909],{"x":58,"y":59,"style":60,"fill":61,"textAnchor":62},"A job that cannot fail loudly will fail quietly for months",[49,911],{"x":442,"y":912,"width":913,"height":83,"rx":914,"fill":115,"stroke":513,"style":104},"48","356","10",[56,916,919],{"x":917,"y":918,"style":108,"fill":513,"textAnchor":62},"194","74","silent script",[49,921],{"x":922,"y":923,"width":924,"height":925,"rx":926,"fill":71,"stroke":72,"style":448},"36","88","316","26","4",[56,928,931],{"x":929,"y":930,"style":476,"fill":79},"50","106","02:00 starting nightly export",[49,933],{"x":922,"y":934,"width":924,"height":925,"rx":926,"fill":71,"stroke":72,"style":448},"122",[56,936,938],{"x":929,"y":937,"style":476,"fill":79},"140","02:04 done",[49,940],{"x":922,"y":941,"width":924,"height":66,"rx":926,"fill":71,"stroke":513,"style":73},"164",[56,943,945],{"x":917,"y":944,"style":125,"fill":513,"textAnchor":62},"182","exit 0 — nobody looks again",[56,947,949],{"x":917,"y":948,"style":125,"fill":79,"textAnchor":62},"198","the output file is empty",[49,951],{"x":952,"y":912,"width":913,"height":83,"rx":914,"fill":102,"stroke":103,"style":104},"388",[56,954,956],{"x":955,"y":918,"style":108,"fill":103,"textAnchor":62},"566","instrumented script",[49,958],{"x":959,"y":923,"width":924,"height":925,"rx":926,"fill":71,"stroke":72,"style":448},"408",[56,961,963],{"x":962,"y":930,"style":476,"fill":79},"422","02:00 source=readings features=0",[49,965],{"x":959,"y":934,"width":924,"height":925,"rx":926,"fill":71,"stroke":72,"style":448},[56,967,968],{"x":962,"y":937,"style":476,"fill":79},"02:00 expected>1000 — aborting",[49,970],{"x":959,"y":941,"width":924,"height":66,"rx":926,"fill":71,"stroke":103,"style":73},[56,972,973],{"x":955,"y":944,"style":125,"fill":103,"textAnchor":62},"exit 2 — the scheduler alerts",[56,975,976],{"x":955,"y":948,"style":125,"fill":79,"textAnchor":62},"yesterday's output is untouched",[154,978,980],{"id":979},"a-job-skeleton-worth-copying","A job skeleton worth copying",[14,982,983],{},"Most headless scripts end up with the same shape, and writing it deliberately once saves rediscovering it under pressure.",[194,985,987],{"className":196,"code":986,"language":198,"meta":199,"style":199},"import os\nimport sys\nimport time\nimport logging\n\nos.environ.setdefault(\"QT_QPA_PLATFORM\", \"offscreen\")\n\nfrom qgis.core import QgsApplication, QgsVectorLayer, QgsProject\n\nlogging.basicConfig(\n    level=logging.INFO,\n    format=\"%(asctime)s %(levelname)-7s %(message)s\",\n    stream=sys.stdout,\n)\nlog = logging.getLogger(\"nightly\")\n\n\ndef build_app():\n    QgsApplication.setPrefixPath(os.environ.get(\"QGIS_PREFIX\", \"\u002Fusr\"), True)\n    app = QgsApplication([], False)\n    app.initQgis()\n    from processing.core.Processing import Processing\n    Processing.initialize()\n    return app\n\n\ndef main(app):\n    started = time.monotonic()\n    source = QgsVectorLayer(os.environ[\"SOURCE_URI\"], \"source\", \"ogr\")\n    if not source.isValid():\n        log.error(\"source did not open: %s\", os.environ[\"SOURCE_URI\"])\n        return 1\n\n    log.info(\"source has %d features\", source.featureCount())\n    # … the actual work …\n    log.info(\"finished in %.1fs\", time.monotonic() - started)\n    return 0\n\n\nif __name__ == \"__main__\":\n    application = build_app()\n    try:\n        sys.exit(main(application))\n    except Exception:\n        log.exception(\"run failed\")\n        sys.exit(1)\n    finally:\n        application.exitQgis()\n",[201,988,989,995,1002,1009,1016,1020,1032,1036,1047,1051,1056,1073,1095,1105,1109,1124,1128,1133,1145,1165,1179,1185,1197,1203,1212,1217,1222,1233,1244,1271,1283,1305,1314,1319,1337,1343,1366,1374,1379,1384,1401,1412,1420,1426,1437,1448,1459,1467],{"__ignoreMap":199},[204,990,991,993],{"class":206,"line":207},[204,992,211],{"class":210},[204,994,215],{"class":214},[204,996,997,999],{"class":206,"line":218},[204,998,211],{"class":210},[204,1000,1001],{"class":214}," sys\n",[204,1003,1004,1006],{"class":206,"line":237},[204,1005,211],{"class":210},[204,1007,1008],{"class":214}," time\n",[204,1010,1011,1013],{"class":206,"line":244},[204,1012,211],{"class":210},[204,1014,1015],{"class":214}," logging\n",[204,1017,1018],{"class":206,"line":258},[204,1019,241],{"emptyLinePlaceholder":240},[204,1021,1022,1024,1026,1028,1030],{"class":206,"line":263},[204,1023,221],{"class":214},[204,1025,225],{"class":224},[204,1027,228],{"class":214},[204,1029,231],{"class":224},[204,1031,234],{"class":214},[204,1033,1034],{"class":206,"line":280},[204,1035,241],{"emptyLinePlaceholder":240},[204,1037,1038,1040,1042,1044],{"class":206,"line":297},[204,1039,247],{"class":210},[204,1041,250],{"class":214},[204,1043,211],{"class":210},[204,1045,1046],{"class":214}," QgsApplication, QgsVectorLayer, QgsProject\n",[204,1048,1049],{"class":206,"line":303},[204,1050,241],{"emptyLinePlaceholder":240},[204,1052,1053],{"class":206,"line":308},[204,1054,1055],{"class":214},"logging.basicConfig(\n",[204,1057,1058,1062,1064,1067,1070],{"class":206,"line":321},[204,1059,1061],{"class":1060},"s9osk","    level",[204,1063,286],{"class":210},[204,1065,1066],{"class":214},"logging.",[204,1068,1069],{"class":274},"INFO",[204,1071,1072],{"class":214},",\n",[204,1074,1075,1078,1080,1082,1085,1088,1091,1093],{"class":206,"line":329},[204,1076,1077],{"class":1060},"    format",[204,1079,286],{"class":210},[204,1081,835],{"class":224},[204,1083,1084],{"class":274},"%(asctime)s",[204,1086,1087],{"class":274}," %(levelname)-7s",[204,1089,1090],{"class":274}," %(message)s",[204,1092,835],{"class":224},[204,1094,1072],{"class":214},[204,1096,1097,1100,1102],{"class":206,"line":335},[204,1098,1099],{"class":1060},"    stream",[204,1101,286],{"class":210},[204,1103,1104],{"class":214},"sys.stdout,\n",[204,1106,1107],{"class":206,"line":340},[204,1108,234],{"class":214},[204,1110,1111,1114,1116,1119,1122],{"class":206,"line":347},[204,1112,1113],{"class":214},"log ",[204,1115,286],{"class":210},[204,1117,1118],{"class":214}," logging.getLogger(",[204,1120,1121],{"class":224},"\"nightly\"",[204,1123,234],{"class":214},[204,1125,1126],{"class":206,"line":352},[204,1127,241],{"emptyLinePlaceholder":240},[204,1129,1131],{"class":206,"line":1130},17,[204,1132,241],{"emptyLinePlaceholder":240},[204,1134,1136,1139,1142],{"class":206,"line":1135},18,[204,1137,1138],{"class":210},"def",[204,1140,1141],{"class":549}," build_app",[204,1143,1144],{"class":214},"():\n",[204,1146,1148,1151,1154,1156,1158,1161,1163],{"class":206,"line":1147},19,[204,1149,1150],{"class":214},"    QgsApplication.setPrefixPath(os.environ.get(",[204,1152,1153],{"class":224},"\"QGIS_PREFIX\"",[204,1155,228],{"class":214},[204,1157,269],{"class":224},[204,1159,1160],{"class":214},"), ",[204,1162,275],{"class":274},[204,1164,234],{"class":214},[204,1166,1168,1171,1173,1175,1177],{"class":206,"line":1167},20,[204,1169,1170],{"class":214},"    app ",[204,1172,286],{"class":210},[204,1174,289],{"class":214},[204,1176,292],{"class":274},[204,1178,234],{"class":214},[204,1180,1182],{"class":206,"line":1181},21,[204,1183,1184],{"class":214},"    app.initQgis()\n",[204,1186,1188,1191,1193,1195],{"class":206,"line":1187},22,[204,1189,1190],{"class":210},"    from",[204,1192,313],{"class":214},[204,1194,211],{"class":210},[204,1196,318],{"class":214},[204,1198,1200],{"class":206,"line":1199},23,[204,1201,1202],{"class":214},"    Processing.initialize()\n",[204,1204,1206,1209],{"class":206,"line":1205},24,[204,1207,1208],{"class":210},"    return",[204,1210,1211],{"class":214}," app\n",[204,1213,1215],{"class":206,"line":1214},25,[204,1216,241],{"emptyLinePlaceholder":240},[204,1218,1220],{"class":206,"line":1219},26,[204,1221,241],{"emptyLinePlaceholder":240},[204,1223,1225,1227,1230],{"class":206,"line":1224},27,[204,1226,1138],{"class":210},[204,1228,1229],{"class":549}," main",[204,1231,1232],{"class":214},"(app):\n",[204,1234,1236,1239,1241],{"class":206,"line":1235},28,[204,1237,1238],{"class":214},"    started ",[204,1240,286],{"class":210},[204,1242,1243],{"class":214}," time.monotonic()\n",[204,1245,1247,1250,1252,1255,1258,1261,1264,1266,1269],{"class":206,"line":1246},29,[204,1248,1249],{"class":214},"    source ",[204,1251,286],{"class":210},[204,1253,1254],{"class":214}," QgsVectorLayer(os.environ[",[204,1256,1257],{"class":224},"\"SOURCE_URI\"",[204,1259,1260],{"class":214},"], ",[204,1262,1263],{"class":224},"\"source\"",[204,1265,228],{"class":214},[204,1267,1268],{"class":224},"\"ogr\"",[204,1270,234],{"class":214},[204,1272,1274,1277,1280],{"class":206,"line":1273},30,[204,1275,1276],{"class":210},"    if",[204,1278,1279],{"class":210}," not",[204,1281,1282],{"class":214}," source.isValid():\n",[204,1284,1286,1289,1292,1295,1297,1300,1302],{"class":206,"line":1285},31,[204,1287,1288],{"class":214},"        log.error(",[204,1290,1291],{"class":224},"\"source did not open: ",[204,1293,1294],{"class":274},"%s",[204,1296,835],{"class":224},[204,1298,1299],{"class":214},", os.environ[",[204,1301,1257],{"class":224},[204,1303,1304],{"class":214},"])\n",[204,1306,1308,1311],{"class":206,"line":1307},32,[204,1309,1310],{"class":210},"        return",[204,1312,1313],{"class":274}," 1\n",[204,1315,1317],{"class":206,"line":1316},33,[204,1318,241],{"emptyLinePlaceholder":240},[204,1320,1322,1325,1328,1331,1334],{"class":206,"line":1321},34,[204,1323,1324],{"class":214},"    log.info(",[204,1326,1327],{"class":224},"\"source has ",[204,1329,1330],{"class":274},"%d",[204,1332,1333],{"class":224}," features\"",[204,1335,1336],{"class":214},", source.featureCount())\n",[204,1338,1340],{"class":206,"line":1339},35,[204,1341,1342],{"class":343},"    # … the actual work …\n",[204,1344,1346,1348,1351,1354,1357,1360,1363],{"class":206,"line":1345},36,[204,1347,1324],{"class":214},[204,1349,1350],{"class":224},"\"finished in ",[204,1352,1353],{"class":274},"%.1f",[204,1355,1356],{"class":224},"s\"",[204,1358,1359],{"class":214},", time.monotonic() ",[204,1361,1362],{"class":210},"-",[204,1364,1365],{"class":214}," started)\n",[204,1367,1369,1371],{"class":206,"line":1368},37,[204,1370,1208],{"class":210},[204,1372,1373],{"class":274}," 0\n",[204,1375,1377],{"class":206,"line":1376},38,[204,1378,241],{"emptyLinePlaceholder":240},[204,1380,1382],{"class":206,"line":1381},39,[204,1383,241],{"emptyLinePlaceholder":240},[204,1385,1387,1389,1392,1395,1398],{"class":206,"line":1386},40,[204,1388,797],{"class":210},[204,1390,1391],{"class":274}," __name__",[204,1393,1394],{"class":210}," ==",[204,1396,1397],{"class":224}," \"__main__\"",[204,1399,1400],{"class":214},":\n",[204,1402,1404,1407,1409],{"class":206,"line":1403},41,[204,1405,1406],{"class":214},"    application ",[204,1408,286],{"class":210},[204,1410,1411],{"class":214}," build_app()\n",[204,1413,1415,1418],{"class":206,"line":1414},42,[204,1416,1417],{"class":210},"    try",[204,1419,1400],{"class":214},[204,1421,1423],{"class":206,"line":1422},43,[204,1424,1425],{"class":214},"        sys.exit(main(application))\n",[204,1427,1429,1432,1435],{"class":206,"line":1428},44,[204,1430,1431],{"class":210},"    except",[204,1433,1434],{"class":274}," Exception",[204,1436,1400],{"class":214},[204,1438,1440,1443,1446],{"class":206,"line":1439},45,[204,1441,1442],{"class":214},"        log.exception(",[204,1444,1445],{"class":224},"\"run failed\"",[204,1447,234],{"class":214},[204,1449,1451,1454,1457],{"class":206,"line":1450},46,[204,1452,1453],{"class":214},"        sys.exit(",[204,1455,1456],{"class":274},"1",[204,1458,234],{"class":214},[204,1460,1462,1465],{"class":206,"line":1461},47,[204,1463,1464],{"class":210},"    finally",[204,1466,1400],{"class":214},[204,1468,1470],{"class":206,"line":1469},48,[204,1471,1472],{"class":214},"        application.exitQgis()\n",[14,1474,1475,1477,1478,1481,1482,1485,1486,1488,1489,1492,1493,1492,1496,1499,1500,1502,1503,1506],{},[359,1476,361],{}," Configuration arrives through environment variables rather than being edited into the file, so the same script runs against test and production data without a diff. ",[201,1479,1480],{},"build_app()"," is separated from ",[201,1483,1484],{},"main()"," so a test can construct the application once and call ",[201,1487,1484],{}," repeatedly. The ",[201,1490,1491],{},"try","\u002F",[201,1494,1495],{},"except",[201,1497,1498],{},"finally"," at the bottom is doing three jobs at once: it guarantees a traceback reaches the log, it converts any escape into a non-zero exit code, and it releases QGIS even on failure so no process lingers holding database connections. Returning an integer from ",[201,1501,1484],{}," rather than calling ",[201,1504,1505],{},"sys.exit()"," inside it keeps the function testable.",[154,1508,1510],{"id":1509},"time-locale-and-the-other-quiet-differences","Time, locale and the other quiet differences",[14,1512,1513],{},"Three environment details break headless runs in ways that are hard to attribute after the fact.",[14,1515,1516,1519,1520,1523,1524,1527,1528,1531],{},[359,1517,1518],{},"The locale decides how numbers parse and print."," A server set to a locale using a comma as the decimal separator will write ",[201,1521,1522],{},"1,5"," where you expected ",[201,1525,1526],{},"1.5",", and a CSV consumer downstream will read it as two columns. Set ",[201,1529,1530],{},"LANG=C.UTF-8"," in the job's environment and the problem disappears permanently.",[14,1533,1534,1537,1538,1541],{},[359,1535,1536],{},"The time zone decides what \"yesterday\" means."," A job that runs at 02:00 local and filters on ",[201,1539,1540],{},"current_date - 1"," produces different rows depending on the server's zone. Do date arithmetic in UTC, or pass the date in explicitly as an argument so a re-run over a past date is possible.",[14,1543,1544,1547,1548,1551],{},[359,1545,1546],{},"The QGIS profile decides which plugins and connections exist."," A server process gets a fresh, empty profile unless ",[201,1549,1550],{},"QGIS_CUSTOM_CONFIG_PATH"," points at one. If your job depends on a provider plugin or a saved database connection, that variable is not optional — and neither is checking, at start-up, that what you depend on is actually present:",[194,1553,1555],{"className":196,"code":1554,"language":198,"meta":199,"style":199},"from qgis.core import QgsApplication\n\nregistry = QgsApplication.processingRegistry()\nif registry.algorithmById(\"native:buffer\") is None:\n    raise RuntimeError(\"Processing did not initialise — check Processing.initialize()\")\n",[201,1556,1557,1567,1571,1581,1602],{"__ignoreMap":199},[204,1558,1559,1561,1563,1565],{"class":206,"line":207},[204,1560,247],{"class":210},[204,1562,250],{"class":214},[204,1564,211],{"class":210},[204,1566,255],{"class":214},[204,1568,1569],{"class":206,"line":218},[204,1570,241],{"emptyLinePlaceholder":240},[204,1572,1573,1576,1578],{"class":206,"line":237},[204,1574,1575],{"class":214},"registry ",[204,1577,286],{"class":210},[204,1579,1580],{"class":214}," QgsApplication.processingRegistry()\n",[204,1582,1583,1585,1588,1591,1594,1597,1600],{"class":206,"line":244},[204,1584,797],{"class":210},[204,1586,1587],{"class":214}," registry.algorithmById(",[204,1589,1590],{"class":224},"\"native:buffer\"",[204,1592,1593],{"class":214},") ",[204,1595,1596],{"class":210},"is",[204,1598,1599],{"class":274}," None",[204,1601,1400],{"class":214},[204,1603,1604,1606,1608,1610,1613],{"class":206,"line":258},[204,1605,811],{"class":210},[204,1607,814],{"class":274},[204,1609,817],{"class":214},[204,1611,1612],{"class":224},"\"Processing did not initialise — check Processing.initialize()\"",[204,1614,234],{"class":214},[14,1616,1617,1619],{},[359,1618,361],{}," Asserting a known algorithm exists is a two-line smoke test that fails immediately and specifically, rather than a thousand lines later with a confusing \"algorithm not found\". The same shape works for a provider plugin: look up one of its algorithm ids and refuse to start without it.",[154,1621,1623],{"id":1622},"reproducing-the-environment","Reproducing the environment",[14,1625,1626,1627,1629],{},"Two machines with \"QGIS 3.34\" can still disagree, because the GDAL, PROJ and Python versions underneath differ, and PROJ in particular decides whether a datum transformation is available. Pinning the whole stack is what containers are for: the official QGIS images fix every version, and the run becomes something you can reproduce in a year. That is the subject of ",[21,1628,169],{"href":168},", which also covers mounting data and getting the PROJ grid files into the image.",[14,1631,1632,1633,1636,1637,410],{},"If containers are not available, a virtual environment created with ",[201,1634,1635],{},"--system-site-packages"," against the system QGIS is the next best thing — the approach described in ",[21,1638,1640],{"href":1639},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002F","Virtual Environments for GIS",[154,1642,1644],{"id":1643},"reaching-data-from-a-server","Reaching data from a server",[14,1646,1647],{},"Data access is where a script that worked on a workstation most often stops working on a server, and the causes are mundane rather than technical.",[14,1649,1650,1653,1654,1657],{},[359,1651,1652],{},"Paths that existed on your machine do not exist on the server."," A project referencing ",[201,1655,1656],{},"\u002Fhome\u002Fyou\u002Fdata\u002Froads.gpkg"," fails, and a project using relative paths fails differently — it resolves them against the project file, which may sit somewhere else entirely. Store the data root in an environment variable and build paths from it, or keep the project's paths relative and place the project in a known location relative to the data.",[14,1659,1660,1663,1664,1667],{},[359,1661,1662],{},"Network shares behave differently."," SQLite-based formats — GeoPackage, SpatiaLite — need real file locking, which SMB and NFS mounts frequently emulate rather than implement. A job that reads from a share is usually fine; a job that ",[177,1665,1666],{},"writes"," to one can corrupt the file. Write locally and copy the finished output into place.",[14,1669,1670,1673,1674,1677,1678,410],{},[359,1671,1672],{},"Credentials must arrive non-interactively."," A stored PostGIS credential lives in the QGIS authentication database, which is unlocked by a master password. On a desktop QGIS prompts for it; on a server the process simply blocks. Point ",[201,1675,1676],{},"QGIS_AUTH_PASSWORD_FILE"," at a file readable only by the job's user, or read the credential from the environment and build the connection without the authentication database at all. Both approaches are set out in ",[21,1679,662],{"href":661},[14,1681,1682,1685],{},[359,1683,1684],{},"Concurrency is a shared-data problem, not a QGIS problem."," Two jobs writing the same GeoPackage will block or corrupt; two jobs writing the same PostGIS table will serialise safely. That difference, rather than dataset size, is usually what decides whether a project needs a database.",[154,1687,1689],{"id":1688},"key-takeaways","Key takeaways",[1691,1692,1693,1706,1717,1726,1732,1738],"ul",{},[1694,1695,1696,1703,1704,410],"li",{},[359,1697,1698,1699,1702],{},"Set ",[201,1700,1701],{},"QT_QPA_PLATFORM=offscreen"," before importing anything from Qt or QGIS",", and construct ",[201,1705,373],{},[1694,1707,1708,1713,1714,1716],{},[359,1709,1710,1712],{},[201,1711,389],{}," is a separate step"," from ",[201,1715,393],{},"; skipping it makes every native algorithm unavailable.",[1694,1718,1719,1725],{},[359,1720,1721,1722,1724],{},"Use ",[201,1723,420],{}," for a single algorithm"," and Python for anything with branching, per-feature work or layout output.",[1694,1727,1728,1731],{},[359,1729,1730],{},"Make every path absolute"," and load projects explicitly — a server has no current project and no reliable working directory.",[1694,1733,1734,1737],{},[359,1735,1736],{},"Log counts, not prose,"," and exit non-zero on failure, so a scheduler can tell the difference between a good night and a bad one.",[1694,1739,1740,1743],{},[359,1741,1742],{},"Pin the stack in a container"," when the result has to be reproducible across machines and months.",[154,1745,1747],{"id":1746},"frequently-asked-questions","Frequently Asked Questions",[14,1749,1750,1753,1754,1757],{},[359,1751,1752],{},"How do I know the run used the QGIS version I think it did?","\nLog it. ",[201,1755,1756],{},"Qgis.QGIS_VERSION"," printed at start-up alongside the GDAL and PROJ versions costs three lines and answers the question that comes up whenever two machines disagree about a coordinate or an algorithm's output.",[14,1759,1760,1763,1764,1767],{},[359,1761,1762],{},"Can one script serve both interactive and headless use?","\nYes, and it is worth arranging. Keep the work in functions that take explicit inputs and return values, put the QGIS bootstrap behind a ",[201,1765,1766],{},"if __name__ == \"__main__\":"," guard, and the same module can be imported into the Python console for exploration and executed by a scheduler unchanged.",[14,1769,1770,1773],{},[359,1771,1772],{},"Do I need QGIS Server to run PyQGIS on a server?","\nNo. QGIS Server is a web map service (WMS, WFS, WMTS); it is not required to run scripts. A headless script needs only the QGIS libraries and Python bindings, which the desktop package already provides.",[14,1775,1776,1779],{},[359,1777,1778],{},"Why does my script hang instead of finishing?","\nSomething is waiting for a user. A missing offscreen platform, a plugin that opens a dialog on load, or an authentication prompt for a stored credential will all block indefinitely. Run with a clean profile and supply the master password non-interactively.",[14,1781,1782,1785,1786,1789],{},[359,1783,1784],{},"Can I run several PyQGIS jobs concurrently on one machine?","\nYes, as separate processes with separate profile directories. One process should not host two ",[201,1787,1788],{},"QgsApplication"," instances, and sharing a profile risks two jobs writing the same settings file.",[14,1791,1792,1795],{},[359,1793,1794],{},"How much memory does a headless run need?","\nThe libraries themselves are modest — a few hundred megabytes. What dominates is the data: an algorithm streaming a GeoPackage stays flat, while a script that materialises every feature in a Python list scales with the dataset.",[14,1797,1798,1801,1802,410],{},[359,1799,1800],{},"Do plugins work headlessly?","\nProcessing provider plugins do, if the profile that contains them is used and they are enabled. Plugins that build GUI elements at load time often do not, which is a good reason to keep algorithm code separate from interface code — see ",[21,1803,1805],{"href":1804},"\u002Fqgis-plugin-development\u002Fprocessing-provider-plugins\u002F","Processing Provider Plugins",[14,1807,1808,1811],{},[359,1809,1810],{},"How do I pass configuration into a scheduled run?","\nEnvironment variables for anything that differs between environments — data roots, database hosts, output directories — and command-line arguments for anything that differs between runs, such as a date. Keeping both out of the source means the same file runs in test and production, and a re-run over last Tuesday is one argument away.",[14,1813,1814,1817],{},[359,1815,1816],{},"Should the job write its output straight into the published location?","\nNo. Write to a temporary path and move the finished file into place once it is complete and validated. A rename within the same filesystem is atomic, so consumers never see a half-written file, and a failed run leaves yesterday's output untouched.",[14,1819,1820,1826],{},[359,1821,1822,1823,1825],{},"Is ",[201,1824,420],{}," slower than a Python script?","\nPer call it pays the QGIS start-up cost, roughly a second or two. Running one algorithm, that is irrelevant; running a thousand in a loop, a single Python process that initialises once is dramatically faster.",[154,1828,1830],{"id":1829},"related-guides","Related Guides",[1691,1832,1833,1839,1843,1849,1855,1861,1865,1869,1873],{},[1694,1834,1835,1836,1838],{},"Up: ",[21,1837,24],{"href":23}," — the parent guide for this topic",[1694,1840,1841],{},[21,1842,1640],{"href":1639},[1694,1844,1845],{},[21,1846,1848],{"href":1847},"\u002Fpyqgis-fundamentals-environment-setup\u002Fdebugging-pyqgis-scripts\u002F","Debugging PyQGIS Scripts",[1694,1850,1851],{},[21,1852,1854],{"href":1853},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002F","Batch Processing with PyQGIS",[1694,1856,1857],{},[21,1858,1860],{"href":1859},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002F","PostGIS and Database Workflows in PyQGIS",[1694,1862,1863],{},[21,1864,164],{"href":163},[1694,1866,1867],{},[21,1868,169],{"href":168},[1694,1870,1871],{},[21,1872,174],{"href":173},[1694,1874,1875],{},[21,1876,184],{"href":183},[1878,1879,1880],"style",{},"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 pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":199,"searchDepth":218,"depth":218,"links":1882},[1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893,1894,1895],{"id":156,"depth":218,"text":157},{"id":188,"depth":218,"text":189},{"id":413,"depth":218,"text":414},{"id":618,"depth":218,"text":619},{"id":665,"depth":218,"text":666},{"id":880,"depth":218,"text":881},{"id":979,"depth":218,"text":980},{"id":1509,"depth":218,"text":1510},{"id":1622,"depth":218,"text":1623},{"id":1643,"depth":218,"text":1644},{"id":1688,"depth":218,"text":1689},{"id":1746,"depth":218,"text":1747},{"id":1829,"depth":218,"text":1830},"Run PyQGIS with no desktop — initialise QGIS on a server, choose between a Python script and qgis_process, containerise it, schedule it, and make failures visible.","md",{"slug":12,"type":1899,"breadcrumb":1900,"datePublished":1901,"dateModified":1901},"guide","Headless QGIS & Server Automation","2026-08-10","\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation",{"title":5,"description":1896},"pyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Findex","Yc_A9retBXftJHapsZ3l-2JU2D3BJ3taHHEyA0uZsag",1786401337550]