[{"data":1,"prerenderedAt":1804},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows":3},{"id":4,"title":5,"body":6,"description":1793,"extension":1794,"meta":1795,"navigation":322,"path":1800,"seo":1801,"stem":1802,"__hash__":1803},"docs\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Findex.md","PostGIS and Database Workflows in PyQGIS",{"type":7,"value":8,"toc":1780},"minimark",[9,13,17,36,227,232,251,258,268,274,280,284,290,458,484,492,579,583,590,695,713,720,804,829,937,941,944,955,1084,1101,1108,1112,1115,1125,1131,1137,1147,1261,1265,1268,1271,1293,1314,1317,1400,1423,1430,1434,1437,1446,1559,1568,1578,1584,1593,1597,1608,1619,1623,1665,1669,1678,1691,1701,1707,1724,1730,1734,1776],[10,11,5],"h1",{"id":12},"postgis-and-database-workflows-in-pyqgis",[14,15,16],"p",{},"There is a point in every automation project where files stop being enough. Two people need to edit the same parcels; a nightly job has to append yesterday's readings without rewriting a hundred-megabyte shapefile; an analyst wants the answer to a question that is three joins and a spatial index away. That is the point at which the data moves into a database — usually PostGIS, sometimes a GeoPackage — and PyQGIS becomes the thing that talks to it.",[14,18,19,20,25,26,30,31,35],{},"This guide sits inside ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002F","Spatial Data Processing & Automation"," and covers the database side of that section. It is the counterpart to the file-based workflows described in ",[21,27,29],{"href":28},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002F","Vector Data Manipulation in PyQGIS",": the same operations, but with a connection string instead of a path, a primary key instead of an implicit row order, and a transaction instead of a save button. If you have not yet met the Processing framework, read ",[21,32,34],{"href":33},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002F","Batch Processing with PyQGIS"," first — almost every algorithm in it accepts a database layer wherever it accepts a file.",[14,37,38],{},[39,40,45,49,53,60,77,86,95,101,106,114,119,123,127,132,137,141,144,148,153,158,162,166,168,172,176,180,185,190,194,198,204,207,212,215,220,223],"svg",{"viewBox":41,"role":42,"ariaLabel":43,"xmlns":44},"0 0 760 300","img","PyQGIS connects to PostGIS through a data source URI and the postgres provider, and to GeoPackage through a file path and the ogr provider, with both paths converging on the same vector layer API","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[46,47,48],"title",{},"How PyQGIS reaches a database",[50,51,52],"desc",{},"A PyQGIS script builds a data source URI describing host, database, schema, table, geometry column and key column. The postgres provider turns that URI into SQL sent to a PostGIS server. A parallel path shows a GeoPackage file read through the ogr provider. Both providers return the same QgsVectorLayer interface to the script, so downstream code is identical.",[54,55],"rect",{"x":56,"y":56,"width":57,"height":58,"fill":59},"0","760","300","#f6f3ea",[61,62,63],"defs",{},[64,65,72],"marker",{"id":66,"viewBox":67,"refX":68,"refY":69,"markerWidth":70,"markerHeight":70,"orient":71},"pgdbArrow","0 0 10 10","8","5","7","auto-start-reverse",[73,74],"path",{"d":75,"fill":76},"M0 0 L10 5 L0 10 z","#2f3b35",[78,79,85],"text",{"x":80,"y":81,"style":82,"fill":83,"textAnchor":84},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","One layer API, two very different things underneath",[54,87],{"x":88,"y":89,"width":90,"height":91,"rx":68,"fill":92,"stroke":93,"style":94},"20","112","136","72","#fffdf7","#0f766e","stroke-width:2.5",[78,96,100],{"x":97,"y":98,"style":99,"fill":93,"textAnchor":84},"88","142","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","your script",[78,102,105],{"x":97,"y":103,"style":104,"fill":76,"textAnchor":84},"162","text-anchor:middle;font-size:11px;font-family:sans-serif","QgsVectorLayer",[54,107],{"x":108,"y":109,"width":110,"height":111,"rx":68,"fill":112,"stroke":93,"style":113},"212","52","180","86","#eef7f4","stroke-width:2",[78,115,118],{"x":116,"y":117,"style":99,"fill":83,"textAnchor":84},"302","78","QgsDataSourceUri",[78,120,122],{"x":116,"y":121,"style":104,"fill":76,"textAnchor":84},"98","host · dbname · schema",[78,124,126],{"x":116,"y":125,"style":104,"fill":76,"textAnchor":84},"115","table · geom · key column",[78,128,131],{"x":116,"y":129,"style":104,"fill":130,"textAnchor":84},"131","#59645f","provider: postgres",[54,133],{"x":108,"y":134,"width":110,"height":111,"rx":68,"fill":135,"stroke":136,"style":113},"166","#fdf2e2","#b45309",[78,138,140],{"x":116,"y":139,"style":99,"fill":83,"textAnchor":84},"192","file path + layername",[78,142,143],{"x":116,"y":108,"style":104,"fill":76,"textAnchor":84},"data.gpkg|layername=roads",[78,145,147],{"x":116,"y":146,"style":104,"fill":130,"textAnchor":84},"232","provider: ogr",[54,149],{"x":150,"y":109,"width":151,"height":111,"rx":68,"fill":92,"stroke":152,"style":113},"448","140","#2563eb",[78,154,157],{"x":155,"y":156,"style":99,"fill":152,"textAnchor":84},"518","82","SQL over TCP",[78,159,161],{"x":155,"y":160,"style":104,"fill":76,"textAnchor":84},"102","filters pushed down",[78,163,165],{"x":155,"y":164,"style":104,"fill":76,"textAnchor":84},"120","to the server",[54,167],{"x":150,"y":134,"width":151,"height":111,"rx":68,"fill":92,"stroke":136,"style":113},[78,169,171],{"x":155,"y":170,"style":99,"fill":136,"textAnchor":84},"196","local file I\u002FO",[78,173,175],{"x":155,"y":174,"style":104,"fill":76,"textAnchor":84},"216","SQLite pages read",[78,177,179],{"x":155,"y":178,"style":104,"fill":76,"textAnchor":84},"234","from disk",[54,181],{"x":182,"y":109,"width":89,"height":183,"rx":68,"fill":184,"stroke":152,"style":94},"628","200","#eff3ff",[78,186,189],{"x":187,"y":188,"style":99,"fill":83,"textAnchor":84},"684","132","storage",[78,191,193],{"x":187,"y":192,"style":104,"fill":76,"textAnchor":84},"154","PostGIS server",[78,195,197],{"x":187,"y":196,"style":104,"fill":76,"textAnchor":84},"174","or .gpkg file",[199,200],"line",{"x1":201,"y1":188,"x2":202,"y2":121,"stroke":76,"style":203},"156","206","stroke-width:2;marker-end:url(#pgdbArrow)",[199,205],{"x1":201,"y1":206,"x2":202,"y2":183,"stroke":76,"style":203},"164",[199,208],{"x1":209,"y1":210,"x2":211,"y2":210,"stroke":76,"style":203},"392","95","442",[199,213],{"x1":209,"y1":214,"x2":211,"y2":214,"stroke":76,"style":203},"209",[199,216],{"x1":217,"y1":210,"x2":218,"y2":219,"stroke":76,"style":203},"588","622","110",[199,221],{"x1":217,"y1":214,"x2":218,"y2":222,"stroke":76,"style":203},"194",[78,224,226],{"x":80,"y":225,"style":104,"fill":130,"textAnchor":84},"278","Everything above the provider line is identical — getFeatures(), Processing, styling, editing",[228,229,231],"h2",{"id":230},"what-a-database-changes-about-your-code","What a database changes about your code",[14,233,234,235,238,239,242,243,246,247,250],{},"The API you already know does not change. A PostGIS table loaded into a ",[236,237,105],"code",{}," iterates with ",[236,240,241],{},"getFeatures()",", feeds ",[236,244,245],{},"processing.run()",", renders with a symbol, and edits with ",[236,248,249],{},"startEditing()"," exactly as a shapefile does. What changes is everything the file format let you ignore.",[14,252,253,257],{},[254,255,256],"strong",{},"Identity becomes explicit."," A shapefile numbers its rows; PostGIS does not promise any order at all. QGIS needs a column that uniquely identifies a row so it can re-read, update and delete individual features. Give it one — an integer primary key is ideal — or you will meet the \"layer is read-only\" and \"feature not found\" failures that account for most first-day frustration.",[14,259,260,263,264,267],{},[254,261,262],{},"Filtering moves to the server."," When you set a subset string or a Processing algorithm asks for features in a rectangle, the postgres provider translates that into a ",[236,265,266],{},"WHERE"," clause. The database evaluates it against its spatial index and returns only matching rows. A file-based layer, by contrast, is filtered in QGIS after reading. On a table of ten million points this is the difference between a query that returns in milliseconds and a script that appears to hang.",[14,269,270,273],{},[254,271,272],{},"Writes become transactional."," Adding features to a GeoPackage or a PostGIS table happens inside a transaction that either commits entirely or rolls back entirely. That is a guarantee no shapefile can offer, and it is the reason a database is the right home for anything a scheduled job writes to unattended.",[14,275,276,279],{},[254,277,278],{},"Credentials become a problem to solve."," A path needs no password. A connection does, and hard-coding one in a script that ends up in version control is the single most common security mistake in GIS automation. QGIS's authentication database exists precisely so the script can refer to a stored credential by id.",[228,281,283],{"id":282},"building-a-connection","Building a connection",[14,285,286,287,289],{},"Everything about a PostGIS layer is encoded in a data source URI. ",[236,288,118],{}," builds one correctly, including the quoting rules that trip up hand-assembled strings.",[291,292,297],"pre",{"className":293,"code":294,"language":295,"meta":296,"style":296},"language-python shiki shiki-themes github-dark","from qgis.core import QgsDataSourceUri, QgsVectorLayer\n\nuri = QgsDataSourceUri()\nuri.setConnection(\"db.example.org\", \"5432\", \"cityworks\", \"gis_reader\", \"\")\nuri.setAuthConfigId(\"a1b2c3d\")               # stored credential, never a literal password\nuri.setDataSource(\"public\", \"parcels\", \"geom\", \"\", \"gid\")\n\nlayer = QgsVectorLayer(uri.uri(False), \"Parcels\", \"postgres\")\nprint(layer.isValid(), layer.featureCount())\n","python","",[236,298,299,317,324,336,370,386,416,421,449],{"__ignoreMap":296},[300,301,303,307,311,314],"span",{"class":199,"line":302},1,[300,304,306],{"class":305},"snl16","from",[300,308,310],{"class":309},"s95oV"," qgis.core ",[300,312,313],{"class":305},"import",[300,315,316],{"class":309}," QgsDataSourceUri, QgsVectorLayer\n",[300,318,320],{"class":199,"line":319},2,[300,321,323],{"emptyLinePlaceholder":322},true,"\n",[300,325,327,330,333],{"class":199,"line":326},3,[300,328,329],{"class":309},"uri ",[300,331,332],{"class":305},"=",[300,334,335],{"class":309}," QgsDataSourceUri()\n",[300,337,339,342,346,349,352,354,357,359,362,364,367],{"class":199,"line":338},4,[300,340,341],{"class":309},"uri.setConnection(",[300,343,345],{"class":344},"sU2Wk","\"db.example.org\"",[300,347,348],{"class":309},", ",[300,350,351],{"class":344},"\"5432\"",[300,353,348],{"class":309},[300,355,356],{"class":344},"\"cityworks\"",[300,358,348],{"class":309},[300,360,361],{"class":344},"\"gis_reader\"",[300,363,348],{"class":309},[300,365,366],{"class":344},"\"\"",[300,368,369],{"class":309},")\n",[300,371,373,376,379,382],{"class":199,"line":372},5,[300,374,375],{"class":309},"uri.setAuthConfigId(",[300,377,378],{"class":344},"\"a1b2c3d\"",[300,380,381],{"class":309},")               ",[300,383,385],{"class":384},"sjoCn","# stored credential, never a literal password\n",[300,387,389,392,395,397,400,402,405,407,409,411,414],{"class":199,"line":388},6,[300,390,391],{"class":309},"uri.setDataSource(",[300,393,394],{"class":344},"\"public\"",[300,396,348],{"class":309},[300,398,399],{"class":344},"\"parcels\"",[300,401,348],{"class":309},[300,403,404],{"class":344},"\"geom\"",[300,406,348],{"class":309},[300,408,366],{"class":344},[300,410,348],{"class":309},[300,412,413],{"class":344},"\"gid\"",[300,415,369],{"class":309},[300,417,419],{"class":199,"line":418},7,[300,420,323],{"emptyLinePlaceholder":322},[300,422,424,427,429,432,436,439,442,444,447],{"class":199,"line":423},8,[300,425,426],{"class":309},"layer ",[300,428,332],{"class":305},[300,430,431],{"class":309}," QgsVectorLayer(uri.uri(",[300,433,435],{"class":434},"sDLfK","False",[300,437,438],{"class":309},"), ",[300,440,441],{"class":344},"\"Parcels\"",[300,443,348],{"class":309},[300,445,446],{"class":344},"\"postgres\"",[300,448,369],{"class":309},[300,450,452,455],{"class":199,"line":451},9,[300,453,454],{"class":434},"print",[300,456,457],{"class":309},"(layer.isValid(), layer.featureCount())\n",[14,459,460,463,464,467,468,471,472,475,476,479,480,483],{},[254,461,462],{},"Breakdown:"," ",[236,465,466],{},"setConnection()"," takes host, port, database, user and password as strings — leave the password empty and supply ",[236,469,470],{},"setAuthConfigId()"," instead, so the secret lives in QGIS's encrypted authentication database rather than in the file. ",[236,473,474],{},"setDataSource()"," takes schema, table, geometry column, an optional SQL filter and the key column; naming the key column explicitly is what makes the layer writable. ",[236,477,478],{},"uri.uri(False)"," renders the string with credentials expanded but omits the password when an auth config is in play. Checking ",[236,481,482],{},"isValid()"," immediately is not optional — an invalid layer fails silently later, often thousands of features into a loop.",[14,485,486,487,491],{},"The full walkthrough, including how to create the auth entry and how to reuse connections already configured in the QGIS browser, is in ",[21,488,490],{"href":489},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fconnect-to-postgis-database-pyqgis\u002F","Connect to a PostGIS Database in PyQGIS",".",[14,493,494],{},[39,495,498,501,504,507,510,516,521,524,527,531,534,538,542,545,548,552,555,559,563,567,571,575],{"viewBox":496,"role":42,"ariaLabel":497,"xmlns":44},"0 0 760 260","Anatomy of a PostGIS data source URI, splitting it into connection parameters, table location, geometry column, key column and optional SQL filter",[46,499,500],{},"Anatomy of a PostGIS data source URI",[50,502,503],{},"A single URI string is broken into five labelled parts. Connection parameters cover host, port and database. Authentication is a stored configuration id. The table part names schema and table. The geometry and key columns tell QGIS which column holds geometry and which identifies a row. An optional SQL clause filters rows on the server.",[54,505],{"x":56,"y":56,"width":57,"height":506,"fill":59},"260",[78,508,509],{"x":80,"y":81,"style":82,"fill":83,"textAnchor":84},"Five decisions hide inside one connection string",[54,511],{"x":512,"y":513,"width":514,"height":515,"rx":68,"fill":112,"stroke":93,"style":113},"16","44","176","60",[78,517,520],{"x":518,"y":519,"style":99,"fill":93,"textAnchor":84},"104","68","where the server is",[78,522,523],{"x":518,"y":97,"style":104,"fill":76,"textAnchor":84},"host · port · dbname",[54,525],{"x":526,"y":513,"width":514,"height":515,"rx":68,"fill":184,"stroke":152,"style":113},"204",[78,528,530],{"x":529,"y":519,"style":99,"fill":152,"textAnchor":84},"292","who you are",[78,532,533],{"x":529,"y":97,"style":104,"fill":76,"textAnchor":84},"authcfg id, not a password",[54,535],{"x":209,"y":513,"width":514,"height":515,"rx":68,"fill":536,"stroke":537,"style":113},"#edf8e9","#15803d",[78,539,541],{"x":540,"y":519,"style":99,"fill":537,"textAnchor":84},"480","which table",[78,543,544],{"x":540,"y":97,"style":104,"fill":76,"textAnchor":84},"schema . table",[54,546],{"x":547,"y":513,"width":206,"height":515,"rx":68,"fill":135,"stroke":136,"style":113},"580",[78,549,551],{"x":550,"y":519,"style":99,"fill":136,"textAnchor":84},"662","which columns",[78,553,554],{"x":550,"y":97,"style":104,"fill":76,"textAnchor":84},"geom · key",[54,556],{"x":512,"y":557,"width":558,"height":109,"rx":68,"fill":92,"stroke":130,"style":113},"126","728",[78,560,562],{"x":80,"y":561,"style":104,"fill":76,"textAnchor":84},"148","dbname='cityworks' host=db.example.org port=5432 authcfg=a1b2c3d",[78,564,566],{"x":80,"y":565,"style":104,"fill":76,"textAnchor":84},"167","key='gid' srid=27700 type=MultiPolygon table=\"public\".\"parcels\" (geom)",[54,568],{"x":512,"y":569,"width":558,"height":570,"rx":68,"fill":135,"stroke":136,"style":113},"198","46",[78,572,574],{"x":80,"y":573,"style":99,"fill":136,"textAnchor":84},"220","Miss the key column and the layer loads read-only",[78,576,578],{"x":80,"y":577,"style":104,"fill":76,"textAnchor":84},"237","QGIS cannot address a row it cannot name",[228,580,582],{"id":581},"layers-that-are-really-queries","Layers that are really queries",[14,584,585,586,589],{},"A PostGIS layer does not have to be a table. Any SQL statement that returns a geometry column and a unique identifier can back a layer, which means the whole expressive power of SQL — joins, window functions, aggregates, ",[236,587,588],{},"ST_"," functions QGIS has no algorithm for — is available as a normal layer you can style, label and export.",[291,591,593],{"className":293,"code":592,"language":295,"meta":296,"style":296},"sql = \"\"\"(\n    SELECT p.gid,\n           p.geom,\n           p.parcel_ref,\n           count(t.id) AS tree_count\n    FROM public.parcels p\n    LEFT JOIN public.trees t ON ST_Contains(p.geom, t.geom)\n    GROUP BY p.gid, p.geom, p.parcel_ref\n)\"\"\"\n\nuri.setDataSource(\"\", sql, \"geom\", \"\", \"gid\")\ntrees_per_parcel = QgsVectorLayer(uri.uri(False), \"Trees per parcel\", \"postgres\")\n",[236,594,595,605,610,615,620,625,630,635,640,645,650,672],{"__ignoreMap":296},[300,596,597,600,602],{"class":199,"line":302},[300,598,599],{"class":309},"sql ",[300,601,332],{"class":305},[300,603,604],{"class":344}," \"\"\"(\n",[300,606,607],{"class":199,"line":319},[300,608,609],{"class":344},"    SELECT p.gid,\n",[300,611,612],{"class":199,"line":326},[300,613,614],{"class":344},"           p.geom,\n",[300,616,617],{"class":199,"line":338},[300,618,619],{"class":344},"           p.parcel_ref,\n",[300,621,622],{"class":199,"line":372},[300,623,624],{"class":344},"           count(t.id) AS tree_count\n",[300,626,627],{"class":199,"line":388},[300,628,629],{"class":344},"    FROM public.parcels p\n",[300,631,632],{"class":199,"line":418},[300,633,634],{"class":344},"    LEFT JOIN public.trees t ON ST_Contains(p.geom, t.geom)\n",[300,636,637],{"class":199,"line":423},[300,638,639],{"class":344},"    GROUP BY p.gid, p.geom, p.parcel_ref\n",[300,641,642],{"class":199,"line":451},[300,643,644],{"class":344},")\"\"\"\n",[300,646,648],{"class":199,"line":647},10,[300,649,323],{"emptyLinePlaceholder":322},[300,651,653,655,657,660,662,664,666,668,670],{"class":199,"line":652},11,[300,654,391],{"class":309},[300,656,366],{"class":344},[300,658,659],{"class":309},", sql, ",[300,661,404],{"class":344},[300,663,348],{"class":309},[300,665,366],{"class":344},[300,667,348],{"class":309},[300,669,413],{"class":344},[300,671,369],{"class":309},[300,673,675,678,680,682,684,686,689,691,693],{"class":199,"line":674},12,[300,676,677],{"class":309},"trees_per_parcel ",[300,679,332],{"class":305},[300,681,431],{"class":309},[300,683,435],{"class":434},[300,685,438],{"class":309},[300,687,688],{"class":344},"\"Trees per parcel\"",[300,690,348],{"class":309},[300,692,446],{"class":344},[300,694,369],{"class":309},[14,696,697,699,700,703,704,707,708,712],{},[254,698,462],{}," The schema argument is empty and the table argument is a parenthesised subquery — that is how the provider distinguishes a query layer from a table. The ",[236,701,702],{},"gid"," passed as the key column must be unique across the result, which is why it appears in the ",[236,705,706],{},"GROUP BY",". The aggregate runs on the server against PostGIS's GiST index, so a spatial count that would take minutes as a per-feature Python loop returns as fast as the database can scan. ",[21,709,711],{"href":710},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis\u002F","Load a PostGIS Query Layer in PyQGIS"," covers the subtleties: declaring the geometry type when the planner cannot infer it, avoiding the full-table scan QGIS runs to guess the extent, and when a materialised view beats a query layer.",[14,714,715,716,719],{},"For statements that return no geometry at all — creating a table, running ",[236,717,718],{},"VACUUM",", updating rows in bulk — use the provider connection API rather than a layer:",[291,721,723],{"className":293,"code":722,"language":295,"meta":296,"style":296},"from qgis.core import QgsProviderRegistry\n\nmetadata = QgsProviderRegistry.instance().providerMetadata(\"postgres\")\nconnection = metadata.createConnection(\"cityworks\")     # a saved browser connection\nrows = connection.executeSql(\"SELECT postgis_version()\")\nprint(rows[0][0])\n",[236,724,725,736,740,754,772,787],{"__ignoreMap":296},[300,726,727,729,731,733],{"class":199,"line":302},[300,728,306],{"class":305},[300,730,310],{"class":309},[300,732,313],{"class":305},[300,734,735],{"class":309}," QgsProviderRegistry\n",[300,737,738],{"class":199,"line":319},[300,739,323],{"emptyLinePlaceholder":322},[300,741,742,745,747,750,752],{"class":199,"line":326},[300,743,744],{"class":309},"metadata ",[300,746,332],{"class":305},[300,748,749],{"class":309}," QgsProviderRegistry.instance().providerMetadata(",[300,751,446],{"class":344},[300,753,369],{"class":309},[300,755,756,759,761,764,766,769],{"class":199,"line":338},[300,757,758],{"class":309},"connection ",[300,760,332],{"class":305},[300,762,763],{"class":309}," metadata.createConnection(",[300,765,356],{"class":344},[300,767,768],{"class":309},")     ",[300,770,771],{"class":384},"# a saved browser connection\n",[300,773,774,777,779,782,785],{"class":199,"line":372},[300,775,776],{"class":309},"rows ",[300,778,332],{"class":305},[300,780,781],{"class":309}," connection.executeSql(",[300,783,784],{"class":344},"\"SELECT postgis_version()\"",[300,786,369],{"class":309},[300,788,789,791,794,796,799,801],{"class":199,"line":388},[300,790,454],{"class":434},[300,792,793],{"class":309},"(rows[",[300,795,56],{"class":434},[300,797,798],{"class":309},"][",[300,800,56],{"class":434},[300,802,803],{"class":309},"])\n",[14,805,806,463,808,811,812,348,815,348,818,348,821,824,825,828],{},[254,807,462],{},[236,809,810],{},"createConnection()"," with a name resolves a connection already saved in the QGIS browser, so the script inherits credentials the user configured interactively. The returned object exposes ",[236,813,814],{},"tables()",[236,816,817],{},"schemas()",[236,819,820],{},"createVectorTable()",[236,822,823],{},"dropVectorTable()"," and ",[236,826,827],{},"executeSql()"," — a small, provider-neutral database API that works identically against GeoPackage and SpatiaLite. Anything returning rows comes back as a list of lists.",[14,830,831],{},[39,832,835,838,841,844,851,854,858,861,865,870,874,876,879,883,888,892,896,900,903,906,911,915,920,923,927,933],{"viewBox":833,"role":42,"ariaLabel":834,"xmlns":44},"0 0 760 268","Decision tree comparing a table layer, a query layer, a materialised view and a direct SQL execution, based on whether the result has geometry and how often it changes",[46,836,837],{},"Choosing between a table, a query layer, a view and raw SQL",[50,839,840],{},"A decision tree starts by asking whether the result carries geometry. Without geometry, use executeSql through the provider connection. With geometry, the next question is how expensive the query is. Cheap queries become query layers evaluated live; expensive ones become materialised views refreshed on a schedule; plain tables need no query at all.",[54,842],{"x":56,"y":56,"width":57,"height":843,"fill":59},"268",[61,845,846],{},[64,847,849],{"id":848,"viewBox":67,"refX":68,"refY":69,"markerWidth":70,"markerHeight":70,"orient":71},"pgdbTreeArrow",[73,850],{"d":75,"fill":130},[78,852,853],{"x":80,"y":81,"style":82,"fill":83,"textAnchor":84},"Four ways to get an answer out of the database",[54,855],{"x":856,"y":513,"width":857,"height":570,"rx":68,"fill":92,"stroke":83,"style":94},"288","184",[78,859,860],{"x":80,"y":91,"style":99,"fill":83,"textAnchor":84},"Does it return geometry?",[54,862],{"x":863,"y":864,"width":170,"height":109,"rx":68,"fill":184,"stroke":152,"style":113},"40","128",[78,866,869],{"x":867,"y":868,"style":99,"fill":152,"textAnchor":84},"138","150","no — executeSql()",[78,871,873],{"x":867,"y":872,"style":104,"fill":76,"textAnchor":84},"169","DDL, updates, counts",[54,875],{"x":856,"y":89,"width":857,"height":570,"rx":68,"fill":92,"stroke":83,"style":94},[78,877,878],{"x":80,"y":151,"style":99,"fill":83,"textAnchor":84},"How costly is it?",[54,880],{"x":881,"y":139,"width":170,"height":882,"rx":68,"fill":536,"stroke":537,"style":113},"252","56",[78,884,887],{"x":885,"y":886,"style":99,"fill":537,"textAnchor":84},"350","214","cheap — query layer",[78,889,891],{"x":885,"y":890,"style":104,"fill":76,"textAnchor":84},"233","always current, no upkeep",[54,893],{"x":894,"y":139,"width":895,"height":882,"rx":68,"fill":135,"stroke":136,"style":113},"492","228",[78,897,899],{"x":898,"y":886,"style":99,"fill":136,"textAnchor":84},"606","costly — materialised view",[78,901,902],{"x":898,"y":890,"style":104,"fill":76,"textAnchor":84},"refresh on a schedule",[54,904],{"x":905,"y":89,"width":526,"height":570,"rx":68,"fill":112,"stroke":93,"style":113},"516",[78,907,910],{"x":908,"y":909,"style":99,"fill":93,"textAnchor":84},"618","134","no query at all",[78,912,914],{"x":908,"y":913,"style":104,"fill":76,"textAnchor":84},"151","plain table layer",[199,916],{"x1":58,"y1":917,"x2":110,"y2":918,"stroke":130,"style":919},"90","124","stroke-width:2;marker-end:url(#pgdbTreeArrow)",[199,921],{"x1":80,"y1":917,"x2":80,"y2":922,"stroke":130,"style":919},"108",[199,924],{"x1":925,"y1":111,"x2":926,"y2":922,"stroke":130,"style":919},"460","540",[199,928],{"x1":929,"y1":930,"x2":931,"y2":932,"stroke":130,"style":919},"352","158","340","188",[199,934],{"x1":935,"y1":930,"x2":936,"y2":932,"stroke":130,"style":919},"430","560",[228,938,940],{"id":939},"writing-results-back","Writing results back",[14,942,943],{},"Analysis that ends in a temporary layer helps nobody. Getting results into durable storage is where the database repays the setup cost, and PyQGIS offers three routes depending on what you are writing.",[14,945,946,947,950,951,954],{},"For a whole layer at once — the output of a Processing chain, a reprojected copy, a nightly extract — ",[236,948,949],{},"QgsVectorFileWriter"," writes to GeoPackage and ",[236,952,953],{},"QgsVectorLayerExporter"," writes to PostGIS:",[291,956,958],{"className":293,"code":957,"language":295,"meta":296,"style":296},"from qgis.core import QgsVectorFileWriter, QgsProject, QgsCoordinateTransformContext\n\noptions = QgsVectorFileWriter.SaveVectorOptions()\noptions.driverName = \"GPKG\"\noptions.layerName = \"flood_zones\"\noptions.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer\n\nerror, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(\n    result_layer,\n    \"\u002Fdata\u002Foutputs\u002Fanalysis.gpkg\",\n    QgsCoordinateTransformContext(),\n    options,\n)\nif error != QgsVectorFileWriter.NoError:\n    raise RuntimeError(message)\n",[236,959,960,971,975,985,995,1005,1015,1019,1029,1034,1042,1047,1052,1057,1072],{"__ignoreMap":296},[300,961,962,964,966,968],{"class":199,"line":302},[300,963,306],{"class":305},[300,965,310],{"class":309},[300,967,313],{"class":305},[300,969,970],{"class":309}," QgsVectorFileWriter, QgsProject, QgsCoordinateTransformContext\n",[300,972,973],{"class":199,"line":319},[300,974,323],{"emptyLinePlaceholder":322},[300,976,977,980,982],{"class":199,"line":326},[300,978,979],{"class":309},"options ",[300,981,332],{"class":305},[300,983,984],{"class":309}," QgsVectorFileWriter.SaveVectorOptions()\n",[300,986,987,990,992],{"class":199,"line":338},[300,988,989],{"class":309},"options.driverName ",[300,991,332],{"class":305},[300,993,994],{"class":344}," \"GPKG\"\n",[300,996,997,1000,1002],{"class":199,"line":372},[300,998,999],{"class":309},"options.layerName ",[300,1001,332],{"class":305},[300,1003,1004],{"class":344}," \"flood_zones\"\n",[300,1006,1007,1010,1012],{"class":199,"line":388},[300,1008,1009],{"class":309},"options.actionOnExistingFile ",[300,1011,332],{"class":305},[300,1013,1014],{"class":309}," QgsVectorFileWriter.CreateOrOverwriteLayer\n",[300,1016,1017],{"class":199,"line":418},[300,1018,323],{"emptyLinePlaceholder":322},[300,1020,1021,1024,1026],{"class":199,"line":423},[300,1022,1023],{"class":309},"error, message, _, _ ",[300,1025,332],{"class":305},[300,1027,1028],{"class":309}," QgsVectorFileWriter.writeAsVectorFormatV3(\n",[300,1030,1031],{"class":199,"line":451},[300,1032,1033],{"class":309},"    result_layer,\n",[300,1035,1036,1039],{"class":199,"line":647},[300,1037,1038],{"class":344},"    \"\u002Fdata\u002Foutputs\u002Fanalysis.gpkg\"",[300,1040,1041],{"class":309},",\n",[300,1043,1044],{"class":199,"line":652},[300,1045,1046],{"class":309},"    QgsCoordinateTransformContext(),\n",[300,1048,1049],{"class":199,"line":674},[300,1050,1051],{"class":309},"    options,\n",[300,1053,1055],{"class":199,"line":1054},13,[300,1056,369],{"class":309},[300,1058,1060,1063,1066,1069],{"class":199,"line":1059},14,[300,1061,1062],{"class":305},"if",[300,1064,1065],{"class":309}," error ",[300,1067,1068],{"class":305},"!=",[300,1070,1071],{"class":309}," QgsVectorFileWriter.NoError:\n",[300,1073,1075,1078,1081],{"class":199,"line":1074},15,[300,1076,1077],{"class":305},"    raise",[300,1079,1080],{"class":434}," RuntimeError",[300,1082,1083],{"class":309},"(message)\n",[14,1085,1086,463,1088,1091,1092,1095,1096,1100],{},[254,1087,462],{},[236,1089,1090],{},"CreateOrOverwriteLayer"," replaces one layer inside the GeoPackage and leaves the others alone — ",[236,1093,1094],{},"CreateOrOverwriteFile"," would delete the whole container, which is a genuinely destructive mistake to make in a nightly job. Checking the returned error code matters because the writer reports failure through its return value rather than by raising. ",[21,1097,1099],{"href":1098},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fwrite-vector-layer-to-geopackage-pyqgis\u002F","Write a Vector Layer to GeoPackage in PyQGIS"," works through the options object, multi-layer containers, and why a GeoPackage is a better default than a shapefile for every output you produce.",[14,1102,1103,1104,491],{},"For incremental writes — appending today's rows to a table that already exists — go through the provider inside a transaction, which is covered in ",[21,1105,1107],{"href":1106},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fappend-features-to-postgis-table-pyqgis\u002F","Append Features to a PostGIS Table in PyQGIS",[228,1109,1111],{"id":1110},"keeping-automated-database-work-safe","Keeping automated database work safe",[14,1113,1114],{},"Unattended scripts touching shared data need a discipline that interactive work can get away with skipping.",[14,1116,1117,1120,1121,1124],{},[254,1118,1119],{},"Never write from the script that reads."," Run analysis against a read-only role and write through a role that only has rights to the output schema. A misdirected ",[236,1122,1123],{},"UPDATE"," in a scheduled job is much cheaper to prevent than to reverse.",[14,1126,1127,1130],{},[254,1128,1129],{},"Wrap multi-step writes in one transaction."," If you delete yesterday's rows and insert today's, do both or neither. Between them the table is empty, and a job that dies in that window leaves the map blank for everyone.",[14,1132,1133,1136],{},[254,1134,1135],{},"Set a statement timeout."," A query that would normally take two seconds can take two hours after a plan flips, and the scheduled job that waits for it silently holds a connection all night.",[14,1138,1139,1142,1143,491],{},[254,1140,1141],{},"Log the row counts."," Every scheduled write should report how many features it read, how many it wrote and how long it took. When a source feed quietly breaks, a count that drops from 40 000 to 12 is the signal that finds it — see the logging patterns in ",[21,1144,1146],{"href":1145},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002F","Headless QGIS and Server Automation",[14,1148,1149],{},[39,1150,1153,1156,1159,1161,1168,1171,1173,1179,1183,1185,1188,1191,1196,1201,1210,1214,1217,1221,1224,1228,1231,1235,1238,1242,1244,1248,1252,1257],{"viewBox":1151,"role":42,"ariaLabel":1152,"xmlns":44},"0 0 760 252","Timeline of a safe nightly database job showing connect, read source, transform, begin transaction, delete and insert, commit, and log counts, with a rollback branch on failure",[46,1154,1155],{},"The shape of a safe nightly database job",[50,1157,1158],{},"A left-to-right timeline runs from connecting with a write role, through reading the source and transforming it, into a transaction that deletes yesterday's rows and inserts today's, then commits and logs the counts. A branch below shows that any failure inside the transaction rolls back, leaving the previous night's data intact.",[54,1160],{"x":56,"y":56,"width":57,"height":881,"fill":59},[61,1162,1163],{},[64,1164,1166],{"id":1165,"viewBox":67,"refX":68,"refY":69,"markerWidth":70,"markerHeight":70,"orient":71},"pgdbJobArrow",[73,1167],{"d":75,"fill":76},[78,1169,1170],{"x":80,"y":81,"style":82,"fill":83,"textAnchor":84},"Either the whole night's data lands, or none of it does",[54,1172],{"x":512,"y":109,"width":864,"height":882,"rx":68,"fill":92,"stroke":130,"style":113},[78,1174,1178],{"x":1175,"y":1176,"style":1177,"fill":83,"textAnchor":84},"80","76","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","connect",[78,1180,1182],{"x":1175,"y":1181,"style":104,"fill":76,"textAnchor":84},"94","write role only",[54,1184],{"x":206,"y":109,"width":864,"height":882,"rx":68,"fill":92,"stroke":130,"style":113},[78,1186,1187],{"x":895,"y":1176,"style":1177,"fill":83,"textAnchor":84},"read + transform",[78,1189,1190],{"x":895,"y":1181,"style":104,"fill":76,"textAnchor":84},"outside the transaction",[54,1192],{"x":1193,"y":1194,"width":1195,"height":1176,"rx":68,"fill":112,"stroke":93,"style":94},"312","42","304",[78,1197,1200],{"x":1198,"y":1199,"style":99,"fill":93,"textAnchor":84},"464","64","one transaction",[54,1202],{"x":1203,"y":1204,"width":1205,"height":1206,"rx":1207,"fill":92,"stroke":1208,"style":1209},"328","74","130","32","6","#b91c1c","stroke-width:1.5",[78,1211,1213],{"x":1212,"y":210,"style":104,"fill":76,"textAnchor":84},"393","delete yesterday",[54,1215],{"x":1216,"y":1204,"width":1205,"height":1206,"rx":1207,"fill":92,"stroke":537,"style":1209},"470",[78,1218,1220],{"x":1219,"y":210,"style":104,"fill":76,"textAnchor":84},"535","insert today",[54,1222],{"x":1223,"y":109,"width":922,"height":882,"rx":68,"fill":536,"stroke":537,"style":94},"636",[78,1225,1227],{"x":1226,"y":1176,"style":1177,"fill":537,"textAnchor":84},"690","commit",[78,1229,1230],{"x":1226,"y":1181,"style":104,"fill":76,"textAnchor":84},"log counts",[199,1232],{"x1":1233,"y1":1175,"x2":930,"y2":1175,"stroke":76,"style":1234},"144","stroke-width:2;marker-end:url(#pgdbJobArrow)",[199,1236],{"x1":529,"y1":1175,"x2":1237,"y2":1175,"stroke":76,"style":1234},"306",[199,1239],{"x1":1240,"y1":1175,"x2":1241,"y2":1175,"stroke":76,"style":1234},"616","630",[54,1243],{"x":1193,"y":930,"width":1195,"height":882,"rx":68,"fill":135,"stroke":1208,"style":94},[78,1245,1247],{"x":1198,"y":1246,"style":99,"fill":1208,"textAnchor":84},"182","any failure — rollback",[78,1249,1251],{"x":1198,"y":1250,"style":104,"fill":76,"textAnchor":84},"201","yesterday's rows are still there",[199,1253],{"x1":1198,"y1":1254,"x2":1198,"y2":1255,"stroke":1208,"style":1256},"118","152","stroke-width:2;stroke-dasharray:5 4;marker-end:url(#pgdbJobArrow)",[78,1258,1260],{"x":80,"y":1259,"style":104,"fill":130,"textAnchor":84},"236","Never leave the table empty between the delete and the insert",[228,1262,1264],{"id":1263},"reading-efficiently-once-the-table-is-large","Reading efficiently once the table is large",[14,1266,1267],{},"A database layer behaves like any other layer, which is exactly what makes it easy to use badly. The provider will happily stream ten million rows into a Python loop if you ask it to, and the request you send decides whether the work happens on the server or on your laptop.",[14,1269,1270],{},"The single most valuable habit is to attach a subset string to the layer rather than filtering in Python:",[291,1272,1274],{"className":293,"code":1273,"language":295,"meta":296,"style":296},"layer.setSubsetString(\"survey_date >= current_date - 30 AND status = 'active'\")\nprint(layer.featureCount())\n",[236,1275,1276,1286],{"__ignoreMap":296},[300,1277,1278,1281,1284],{"class":199,"line":302},[300,1279,1280],{"class":309},"layer.setSubsetString(",[300,1282,1283],{"class":344},"\"survey_date >= current_date - 30 AND status = 'active'\"",[300,1285,369],{"class":309},[300,1287,1288,1290],{"class":199,"line":319},[300,1289,454],{"class":434},[300,1291,1292],{"class":309},"(layer.featureCount())\n",[14,1294,1295,1297,1298,1300,1301,1304,1305,1309,1310,1313],{},[254,1296,462],{}," The subset string is appended to the provider's ",[236,1299,266],{}," clause, so PostgreSQL evaluates it against its indexes and ",[236,1302,1303],{},"featureCount()"," returns the filtered number without a single feature crossing the network. The expression is SQL — not QGIS expression syntax — because it is passed through to the database, which is a genuine difference from the expression engine described in ",[21,1306,1308],{"href":1307},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002F","Working with QGIS Expressions",". Clearing it is ",[236,1311,1312],{},"setSubsetString(\"\")",", and forgetting to clear it is a common source of \"half my data disappeared\".",[14,1315,1316],{},"For a per-feature loop, narrow the request as well:",[291,1318,1320],{"className":293,"code":1319,"language":295,"meta":296,"style":296},"from qgis.core import QgsFeatureRequest\n\nrequest = (\n    QgsFeatureRequest()\n    .setSubsetOfAttributes([\"parcel_ref\", \"area_m2\"], layer.fields())\n    .setFilterRect(canvas_extent)\n)\n\nfor feature in layer.getFeatures(request):\n    process(feature)\n",[236,1321,1322,1333,1337,1347,1352,1368,1373,1377,1381,1395],{"__ignoreMap":296},[300,1323,1324,1326,1328,1330],{"class":199,"line":302},[300,1325,306],{"class":305},[300,1327,310],{"class":309},[300,1329,313],{"class":305},[300,1331,1332],{"class":309}," QgsFeatureRequest\n",[300,1334,1335],{"class":199,"line":319},[300,1336,323],{"emptyLinePlaceholder":322},[300,1338,1339,1342,1344],{"class":199,"line":326},[300,1340,1341],{"class":309},"request ",[300,1343,332],{"class":305},[300,1345,1346],{"class":309}," (\n",[300,1348,1349],{"class":199,"line":338},[300,1350,1351],{"class":309},"    QgsFeatureRequest()\n",[300,1353,1354,1357,1360,1362,1365],{"class":199,"line":372},[300,1355,1356],{"class":309},"    .setSubsetOfAttributes([",[300,1358,1359],{"class":344},"\"parcel_ref\"",[300,1361,348],{"class":309},[300,1363,1364],{"class":344},"\"area_m2\"",[300,1366,1367],{"class":309},"], layer.fields())\n",[300,1369,1370],{"class":199,"line":388},[300,1371,1372],{"class":309},"    .setFilterRect(canvas_extent)\n",[300,1374,1375],{"class":199,"line":418},[300,1376,369],{"class":309},[300,1378,1379],{"class":199,"line":423},[300,1380,323],{"emptyLinePlaceholder":322},[300,1382,1383,1386,1389,1392],{"class":199,"line":451},[300,1384,1385],{"class":305},"for",[300,1387,1388],{"class":309}," feature ",[300,1390,1391],{"class":305},"in",[300,1393,1394],{"class":309}," layer.getFeatures(request):\n",[300,1396,1397],{"class":199,"line":647},[300,1398,1399],{"class":309},"    process(feature)\n",[14,1401,1402,1404,1405,1408,1409,1412,1413,1417,1418,1422],{},[254,1403,462],{}," The attribute subset becomes a narrower ",[236,1406,1407],{},"SELECT",", and the rectangle becomes an ",[236,1410,1411],{},"ST_Intersects"," against the GiST index — both evaluated by PostGIS. On a wide table this routinely turns a minute into a second, and it is the same lever covered in detail in ",[21,1414,1416],{"href":1415},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fspeed-up-feature-iteration-with-qgsfeaturerequest-pyqgis\u002F","Speed Up Feature Iteration with QgsFeatureRequest",". Two things that do ",[1419,1420,1421],"em",{},"not"," get pushed down are worth knowing: a QGIS expression using functions PostgreSQL has no equivalent for, and any ordering the provider cannot express — both quietly fall back to evaluation inside QGIS.",[14,1424,1425,1426,1429],{},"Finally, remember that the database has its own diagnostics. ",[236,1427,1428],{},"connection.executeSql(\"EXPLAIN ANALYZE \" + statement)"," returns PostgreSQL's own plan, which answers \"why is this slow\" far more directly than timing the Python around it.",[228,1431,1433],{"id":1432},"moving-a-file-based-workflow-into-the-database","Moving a file-based workflow into the database",[14,1435,1436],{},"Most projects arrive at PostGIS from a folder of shapefiles, and the migration is more about discipline than code. Four decisions do most of the work.",[14,1438,1439,463,1442,1445],{},[254,1440,1441],{},"Load once, with the types you want.",[236,1443,1444],{},"QgsVectorLayerExporter.exportLayer()"," creates the table and copies the features in one call, inferring column types from the source. Where the source is a shapefile, that inference carries the shapefile's limitations — dates as strings, everything truncated to ten characters — so create the table explicitly first when the schema matters:",[291,1447,1449],{"className":293,"code":1448,"language":295,"meta":296,"style":296},"from qgis.core import QgsVectorLayerExporter, QgsCoordinateReferenceSystem\n\nerror, message = QgsVectorLayerExporter.exportLayer(\n    source_layer,\n    uri.uri(False),\n    \"postgres\",\n    QgsCoordinateReferenceSystem(\"EPSG:27700\"),\n    False,                       # onlySelected\n    {\"overwrite\": True},\n)\nif error != QgsVectorLayerExporter.NoError:\n    raise RuntimeError(message)\n",[236,1450,1451,1462,1466,1476,1481,1491,1498,1508,1519,1536,1540,1551],{"__ignoreMap":296},[300,1452,1453,1455,1457,1459],{"class":199,"line":302},[300,1454,306],{"class":305},[300,1456,310],{"class":309},[300,1458,313],{"class":305},[300,1460,1461],{"class":309}," QgsVectorLayerExporter, QgsCoordinateReferenceSystem\n",[300,1463,1464],{"class":199,"line":319},[300,1465,323],{"emptyLinePlaceholder":322},[300,1467,1468,1471,1473],{"class":199,"line":326},[300,1469,1470],{"class":309},"error, message ",[300,1472,332],{"class":305},[300,1474,1475],{"class":309}," QgsVectorLayerExporter.exportLayer(\n",[300,1477,1478],{"class":199,"line":338},[300,1479,1480],{"class":309},"    source_layer,\n",[300,1482,1483,1486,1488],{"class":199,"line":372},[300,1484,1485],{"class":309},"    uri.uri(",[300,1487,435],{"class":434},[300,1489,1490],{"class":309},"),\n",[300,1492,1493,1496],{"class":199,"line":388},[300,1494,1495],{"class":344},"    \"postgres\"",[300,1497,1041],{"class":309},[300,1499,1500,1503,1506],{"class":199,"line":418},[300,1501,1502],{"class":309},"    QgsCoordinateReferenceSystem(",[300,1504,1505],{"class":344},"\"EPSG:27700\"",[300,1507,1490],{"class":309},[300,1509,1510,1513,1516],{"class":199,"line":423},[300,1511,1512],{"class":434},"    False",[300,1514,1515],{"class":309},",                       ",[300,1517,1518],{"class":384},"# onlySelected\n",[300,1520,1521,1524,1527,1530,1533],{"class":199,"line":451},[300,1522,1523],{"class":309},"    {",[300,1525,1526],{"class":344},"\"overwrite\"",[300,1528,1529],{"class":309},": ",[300,1531,1532],{"class":434},"True",[300,1534,1535],{"class":309},"},\n",[300,1537,1538],{"class":199,"line":647},[300,1539,369],{"class":309},[300,1541,1542,1544,1546,1548],{"class":199,"line":652},[300,1543,1062],{"class":305},[300,1545,1065],{"class":309},[300,1547,1068],{"class":305},[300,1549,1550],{"class":309}," QgsVectorLayerExporter.NoError:\n",[300,1552,1553,1555,1557],{"class":199,"line":674},[300,1554,1077],{"class":305},[300,1556,1080],{"class":434},[300,1558,1083],{"class":309},[14,1560,1561,1563,1564,1567],{},[254,1562,462],{}," The options dictionary passes provider-specific flags; ",[236,1565,1566],{},"overwrite"," replaces an existing table, and its absence makes the export fail rather than silently append. Naming the CRS explicitly sets the geometry column's SRID constraint, which is what later rejects mis-projected inserts instead of storing them. The error code is returned rather than raised, following the same pattern as the file writer.",[14,1569,1570,1573,1574,1577],{},[254,1571,1572],{},"Add the constraints the files never had."," A primary key, a ",[236,1575,1576],{},"NOT NULL"," on the columns that matter, a unique constraint on the natural key, and a spatial index. Each one converts a silent data problem into an immediate, specific error.",[14,1579,1580,1583],{},[254,1581,1582],{},"Decide what is authoritative."," A pipeline that reads shapefiles and writes PostGIS every night has two copies of the truth and will eventually disagree with itself. Pick one, and make the other a derived export.",[14,1585,1586,1589,1590,491],{},[254,1587,1588],{},"Keep the outputs in a separate schema"," from the inputs, with different roles for reading and writing. That single arrangement prevents an entire category of accident, and costs one ",[236,1591,1592],{},"CREATE SCHEMA",[228,1594,1596],{"id":1595},"geopackage-as-the-small-end-of-the-same-idea","GeoPackage as the small end of the same idea",[14,1598,1599,1600,1603,1604,1607],{},"Not every project needs a server. A GeoPackage is a single SQLite file with the same fundamentals — tables, indexes, transactions, SQL — and PyQGIS reaches it through the ",[236,1601,1602],{},"ogr"," provider with a ",[236,1605,1606],{},"path|layername=name"," data source. It holds many layers, stores styles, survives being emailed, and does not corrupt the way a shapefile does when a column name exceeds ten characters or a value contains a non-ASCII character.",[14,1609,1610,1611,1613,1614,1618],{},"Use a GeoPackage when one machine writes at a time, and PostGIS when several people or processes write concurrently. The migration path between them is short: the same ",[236,1612,105],{},", the same algorithms, and one changed line where the data source is built. That is the practical reason to write file-based automation against the database-shaped API from the beginning — see ",[21,1615,1617],{"href":1616},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fautomating-shapefile-to-geojson-conversion\u002F","Automating Shapefile to GeoJSON Conversion"," for the equivalent format-conversion patterns.",[228,1620,1622],{"id":1621},"key-takeaways","Key takeaways",[1624,1625,1626,1633,1639,1647,1653,1659],"ul",{},[1627,1628,1629,1632],"li",{},[254,1630,1631],{},"Name the key column."," A PostGIS layer without a unique integer identifier loads read-only and misbehaves on re-reads. This single omission causes most beginner failures.",[1627,1634,1635,1638],{},[254,1636,1637],{},"Let the server filter."," Subset strings and bounding-box requests are pushed down to PostGIS and answered by its index; filtering in Python after reading throws away the entire benefit of the database.",[1627,1640,1641,463,1644,1646],{},[254,1642,1643],{},"Keep secrets in the authentication database.",[236,1645,470],{}," exists so no script ever needs a literal password.",[1627,1648,1649,1652],{},[254,1650,1651],{},"A query can be a layer."," Any SQL returning geometry and a unique id backs a stylable, exportable layer — often replacing an entire Processing chain.",[1627,1654,1655,1658],{},[254,1656,1657],{},"Write transactionally."," Delete-then-insert belongs inside one transaction so a failure never leaves the table empty.",[1627,1660,1661,1664],{},[254,1662,1663],{},"GeoPackage first, PostGIS when shared."," The API is the same; only the concurrency guarantees differ.",[228,1666,1668],{"id":1667},"frequently-asked-questions","Frequently Asked Questions",[14,1670,1671,1674,1675,1677],{},[254,1672,1673],{},"Why is my PostGIS layer read-only?","\nAlmost always because QGIS could not identify a unique key column. Pass the primary key explicitly as the last argument to ",[236,1676,474],{},". For query layers, make sure the column you nominate really is unique across the result — a join that duplicates rows silently breaks identity even when the column is a primary key in its own table.",[14,1679,1680,1683,1684,1687,1688,1690],{},[254,1681,1682],{},"Should I use psycopg2 instead of the postgres provider?","\nFor pure SQL with no map involved, a direct driver is perfectly reasonable, but it adds a dependency QGIS does not ship and duplicates connection handling. The provider connection API — ",[236,1685,1686],{},"QgsProviderRegistry.instance().providerMetadata(\"postgres\").createConnection(name)"," — gives you ",[236,1689,827],{}," using the credentials the user already configured, which is usually the better trade in a plugin.",[14,1692,1693,1696,1697,1700],{},[254,1694,1695],{},"How do I avoid storing a password in my script?","\nCreate an authentication configuration in QGIS (Settings → Options → Authentication), note its id, and call ",[236,1698,1699],{},"uri.setAuthConfigId(id)",". The password is stored encrypted in the QGIS authentication database and never appears in your source. For servers, an environment variable read at startup, or a PostgreSQL service file, are the usual alternatives.",[14,1702,1703,1706],{},[254,1704,1705],{},"Is GeoPackage fast enough for large datasets?","\nFor a single reader or writer, yes — SQLite handles tens of millions of rows with a spatial index. It falls down on concurrency: writers take an exclusive lock, so two scheduled jobs writing at once will block each other. That constraint, rather than raw size, is what pushes a project to PostGIS.",[14,1708,1709,1712,1713,1716,1717,1720,1721,1723],{},[254,1710,1711],{},"Can Processing algorithms write straight into PostGIS?","\nYes. Pass a ",[236,1714,1715],{},"postgres:"," output URI where the algorithm expects an ",[236,1718,1719],{},"OUTPUT",", or run the algorithm to a temporary layer and export it with ",[236,1722,953],{},". The second form is easier to reason about because the write becomes an explicit, loggable step in your script.",[14,1725,1726,1729],{},[254,1727,1728],{},"Why is loading my query layer so slow before anything renders?","\nQGIS runs the query once to determine the extent and geometry type. Declare both in the URI — set the geometry type and SRID when building the data source — so it can skip that probe, or back the query with a materialised view that carries proper statistics.",[228,1731,1733],{"id":1732},"related-guides","Related Guides",[1624,1735,1736,1742,1746,1750,1756,1760,1764,1768,1772],{},[1627,1737,1738,1739,1741],{},"Up: ",[21,1740,24],{"href":23}," — the parent guide for this topic",[1627,1743,1744],{},[21,1745,29],{"href":28},[1627,1747,1748],{},[21,1749,34],{"href":33},[1627,1751,1752],{},[21,1753,1755],{"href":1754},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002F","Attribute Tables and Field Management in PyQGIS",[1627,1757,1758],{},[21,1759,1146],{"href":1145},[1627,1761,1762],{},[21,1763,490],{"href":489},[1627,1765,1766],{},[21,1767,711],{"href":710},[1627,1769,1770],{},[21,1771,1099],{"href":1098},[1627,1773,1774],{},[21,1775,1107],{"href":1106},[1777,1778,1779],"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 .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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);}",{"title":296,"searchDepth":319,"depth":319,"links":1781},[1782,1783,1784,1785,1786,1787,1788,1789,1790,1791,1792],{"id":230,"depth":319,"text":231},{"id":282,"depth":319,"text":283},{"id":581,"depth":319,"text":582},{"id":939,"depth":319,"text":940},{"id":1110,"depth":319,"text":1111},{"id":1263,"depth":319,"text":1264},{"id":1432,"depth":319,"text":1433},{"id":1595,"depth":319,"text":1596},{"id":1621,"depth":319,"text":1622},{"id":1667,"depth":319,"text":1668},{"id":1732,"depth":319,"text":1733},"Connect PyQGIS to PostGIS and GeoPackage, build layers from SQL, write results back to the database, and keep credentials, primary keys and transactions under control.","md",{"slug":1796,"type":1797,"breadcrumb":1798,"datePublished":1799,"dateModified":1799},"postgis-and-database-workflows","guide","PostGIS & Database Workflows","2026-08-10","\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows",{"title":5,"description":1793},"spatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Findex","j-zbT4lR65DJWu_k9B8choHT2hx524ew2SDwQwXAqD0",1786401338488]