[{"data":1,"prerenderedAt":1461},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fexecute-sql-on-postgis-pyqgis":3},{"id":4,"title":5,"body":6,"description":1450,"extension":1451,"meta":1452,"navigation":239,"path":1457,"seo":1458,"stem":1459,"__hash__":1460},"docs\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fexecute-sql-on-postgis-pyqgis\u002Findex.md","Execute SQL on PostGIS from PyQGIS",{"type":7,"value":8,"toc":1437},"minimark",[9,13,22,31,172,177,199,203,206,331,358,361,447,463,467,470,565,577,580,614,626,630,633,695,710,713,790,799,883,887,890,1021,1037,1041,1047,1178,1187,1191,1197,1284,1288,1341,1345,1351,1355,1365,1378,1387,1402,1406,1433],[10,11,5],"h1",{"id":12},"execute-sql-on-postgis-from-pyqgis",[14,15,16,17,21],"p",{},"Sooner or later a PostGIS workflow needs something the layer API does not offer: create an index, refresh a materialised view, run an ",[18,19,20],"code",{},"UPDATE"," across ten million rows, or ask a question whose answer is a number rather than a layer. QGIS's provider connection API gives you a direct SQL channel using the credentials the project already holds — no second connection library, no duplicated password.",[14,23,24,25,30],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002F","PostGIS & Database Workflows in PyQGIS",". It covers getting a connection object, executing statements and reading results, parameterising safely, wrapping work in a transaction, and turning a query into a map layer.",[14,32,33],{},[34,35,40,44,48,55,72,81,91,97,102,106,110,115,120,123,126,129,132,135,140,144,147,150,153,156,163,168],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 316","img","Three routes from PyQGIS to a PostGIS database: a layer through the provider, a query layer with a declared key, and a direct SQL connection, each suited to different work","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"Three ways into the same database",[45,46,47],"desc",{},"A vector layer through the postgres provider is for viewing and editing a table. A query layer wraps a SELECT and needs a unique key column declared. The provider connection is a direct SQL channel for statements that return no geometry, for schema changes and for maintenance.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","316","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"pgArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#2f3b35",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Pick the channel that matches the question",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"20","56","228","150","10","#eff3ff","#2563eb","stroke-width:2.5",[73,92,96],{"x":93,"y":94,"style":95,"fill":89,"textAnchor":79},"134","84","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","vector layer",[73,98,101],{"x":93,"y":99,"style":100,"fill":71,"textAnchor":79},"110","text-anchor:middle;font-size:10.5px;font-family:monospace","QgsVectorLayer",[73,103,105],{"x":93,"y":93,"style":104,"fill":71,"textAnchor":79},"text-anchor:middle;font-size:10.5px;font-family:sans-serif","view and edit a table",[73,107,109],{"x":93,"y":108,"style":104,"fill":71,"textAnchor":79},"158","spatial index used",[73,111,114],{"x":93,"y":112,"style":113,"fill":89,"textAnchor":79},"184","text-anchor:middle;font-size:10px;font-family:sans-serif","the default choice",[49,116],{"x":117,"y":84,"width":85,"height":86,"rx":87,"fill":118,"stroke":119,"style":90},"266","#fdf2e2","#b45309",[73,121,122],{"x":75,"y":94,"style":95,"fill":119,"textAnchor":79},"query layer",[73,124,125],{"x":75,"y":99,"style":104,"fill":71,"textAnchor":79},"a SELECT as a layer",[73,127,128],{"x":75,"y":93,"style":104,"fill":71,"textAnchor":79},"needs a unique key",[73,130,131],{"x":75,"y":108,"style":104,"fill":71,"textAnchor":79},"read-only",[73,133,134],{"x":75,"y":112,"style":113,"fill":119,"textAnchor":79},"joins and aggregates",[49,136],{"x":137,"y":84,"width":85,"height":86,"rx":87,"fill":138,"stroke":139,"style":90},"512","#edf8e9","#15803d",[73,141,143],{"x":142,"y":94,"style":95,"fill":139,"textAnchor":79},"626","provider connection",[73,145,146],{"x":142,"y":99,"style":104,"fill":71,"textAnchor":79},"arbitrary SQL",[73,148,149],{"x":142,"y":93,"style":104,"fill":71,"textAnchor":79},"DDL, UPDATE, VACUUM",[73,151,152],{"x":142,"y":108,"style":104,"fill":71,"textAnchor":79},"returns rows, not features",[73,154,155],{"x":142,"y":112,"style":113,"fill":139,"textAnchor":79},"this page",[49,157],{"x":86,"y":158,"width":159,"height":160,"rx":63,"fill":161,"stroke":162,"style":90},"238","460","60","#fffdf7","#0f766e",[73,164,167],{"x":75,"y":165,"style":166,"fill":162,"textAnchor":79},"264","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","all three reuse the stored connection",[73,169,171],{"x":75,"y":170,"style":104,"fill":71,"textAnchor":79},"286","one set of credentials, managed in one place",[173,174,176],"h2",{"id":175},"prerequisites","Prerequisites",[178,179,180,188,196],"ul",{},[181,182,183,187],"li",{},[184,185,186],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer with the PostgreSQL provider available.",[181,189,190,191,195],{},"A saved PostGIS connection in QGIS, or the connection parameters to build one. See ",[26,192,194],{"href":193},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fconnect-to-postgis-database-pyqgis\u002F","connecting to a PostGIS database",".",[181,197,198],{},"Database privileges appropriate to what you intend to run. A script that creates indexes needs more than one that selects.",[173,200,202],{"id":201},"get-a-connection-object","Get a connection object",[14,204,205],{},"The provider registry hands out connection objects for any saved connection.",[207,208,213],"pre",{"className":209,"code":210,"language":211,"meta":212,"style":212},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProviderRegistry\n\nmetadata = QgsProviderRegistry.instance().providerMetadata(\"postgres\")\nprint(sorted(metadata.connections().keys()))\n\nconnection = metadata.findConnection(\"survey_db\")\nif connection is None:\n    raise LookupError(\"no saved connection named 'survey_db'\")\n","python","",[18,214,215,234,241,260,276,281,297,315],{"__ignoreMap":212},[216,217,220,224,228,231],"span",{"class":218,"line":219},"line",1,[216,221,223],{"class":222},"snl16","from",[216,225,227],{"class":226},"s95oV"," qgis.core ",[216,229,230],{"class":222},"import",[216,232,233],{"class":226}," QgsProviderRegistry\n",[216,235,237],{"class":218,"line":236},2,[216,238,240],{"emptyLinePlaceholder":239},true,"\n",[216,242,244,247,250,253,257],{"class":218,"line":243},3,[216,245,246],{"class":226},"metadata ",[216,248,249],{"class":222},"=",[216,251,252],{"class":226}," QgsProviderRegistry.instance().providerMetadata(",[216,254,256],{"class":255},"sU2Wk","\"postgres\"",[216,258,259],{"class":226},")\n",[216,261,263,267,270,273],{"class":218,"line":262},4,[216,264,266],{"class":265},"sDLfK","print",[216,268,269],{"class":226},"(",[216,271,272],{"class":265},"sorted",[216,274,275],{"class":226},"(metadata.connections().keys()))\n",[216,277,279],{"class":218,"line":278},5,[216,280,240],{"emptyLinePlaceholder":239},[216,282,284,287,289,292,295],{"class":218,"line":283},6,[216,285,286],{"class":226},"connection ",[216,288,249],{"class":222},[216,290,291],{"class":226}," metadata.findConnection(",[216,293,294],{"class":255},"\"survey_db\"",[216,296,259],{"class":226},[216,298,300,303,306,309,312],{"class":218,"line":299},7,[216,301,302],{"class":222},"if",[216,304,305],{"class":226}," connection ",[216,307,308],{"class":222},"is",[216,310,311],{"class":265}," None",[216,313,314],{"class":226},":\n",[216,316,318,321,324,326,329],{"class":218,"line":317},8,[216,319,320],{"class":222},"    raise",[216,322,323],{"class":265}," LookupError",[216,325,269],{"class":226},[216,327,328],{"class":255},"\"no saved connection named 'survey_db'\"",[216,330,259],{"class":226},[14,332,333,336,337,340,341,344,345,348,349,352,353,357],{},[184,334,335],{},"Breakdown:"," ",[18,338,339],{},"connections()"," returns the saved connections by name — the same list the browser panel shows — so a script can discover what is available rather than hard-coding a URI. ",[18,342,343],{},"findConnection()"," returns ",[18,346,347],{},"None"," for an unknown name, and the guard turns that into a clear message rather than an ",[18,350,351],{},"AttributeError"," three lines later. The connection object carries the stored credentials, including any ",[26,354,356],{"href":355},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fstore-credentials-with-qgsauthmanager-pyqgis\u002F","authentication configuration",", so no password appears in the script.",[14,359,360],{},"Where no saved connection exists — a headless run on a fresh machine — build one from a URI instead:",[207,362,364],{"className":209,"code":363,"language":211,"meta":212,"style":212},"from qgis.core import QgsDataSourceUri\n\nuri = QgsDataSourceUri()\nuri.setConnection(\"db.example.org\", \"5432\", \"survey\", \"reader\", \"\")\nuri.setAuthConfigId(\"abc1234\")\nconnection = metadata.createConnection(uri.uri(False), {})\n",[18,365,366,377,381,391,422,432],{"__ignoreMap":212},[216,367,368,370,372,374],{"class":218,"line":219},[216,369,223],{"class":222},[216,371,227],{"class":226},[216,373,230],{"class":222},[216,375,376],{"class":226}," QgsDataSourceUri\n",[216,378,379],{"class":218,"line":236},[216,380,240],{"emptyLinePlaceholder":239},[216,382,383,386,388],{"class":218,"line":243},[216,384,385],{"class":226},"uri ",[216,387,249],{"class":222},[216,389,390],{"class":226}," QgsDataSourceUri()\n",[216,392,393,396,399,402,405,407,410,412,415,417,420],{"class":218,"line":262},[216,394,395],{"class":226},"uri.setConnection(",[216,397,398],{"class":255},"\"db.example.org\"",[216,400,401],{"class":226},", ",[216,403,404],{"class":255},"\"5432\"",[216,406,401],{"class":226},[216,408,409],{"class":255},"\"survey\"",[216,411,401],{"class":226},[216,413,414],{"class":255},"\"reader\"",[216,416,401],{"class":226},[216,418,419],{"class":255},"\"\"",[216,421,259],{"class":226},[216,423,424,427,430],{"class":218,"line":278},[216,425,426],{"class":226},"uri.setAuthConfigId(",[216,428,429],{"class":255},"\"abc1234\"",[216,431,259],{"class":226},[216,433,434,436,438,441,444],{"class":218,"line":283},[216,435,286],{"class":226},[216,437,249],{"class":222},[216,439,440],{"class":226}," metadata.createConnection(uri.uri(",[216,442,443],{"class":265},"False",[216,445,446],{"class":226},"), {})\n",[14,448,449,336,451,454,455,458,459,462],{},[184,450,335],{},[18,452,453],{},"setAuthConfigId()"," points at an entry in the authentication database rather than embedding a password, which is what keeps credentials out of source control. ",[18,456,457],{},"uri(False)"," omits the password from the string. ",[18,460,461],{},"createConnection()"," builds a connection object without saving it into the user's settings, which is the right behaviour for a script that should not modify the user's environment.",[173,464,466],{"id":465},"execute-a-statement","Execute a statement",[14,468,469],{},"Two methods cover everything: one that returns rows and one that does not.",[207,471,473],{"className":209,"code":472,"language":211,"meta":212,"style":212},"rows = connection.executeSql(\n    \"SELECT district, count(*) AS n, round(sum(area_m2)\u002F10000.0, 1) AS ha \"\n    \"FROM survey.parcels GROUP BY district ORDER BY n DESC\"\n)\nfor district, count, hectares in rows:\n    print(f\"{district:\u003C20} {count:>6}  {hectares:>10} ha\")\n",[18,474,475,485,490,495,499,513],{"__ignoreMap":212},[216,476,477,480,482],{"class":218,"line":219},[216,478,479],{"class":226},"rows ",[216,481,249],{"class":222},[216,483,484],{"class":226}," connection.executeSql(\n",[216,486,487],{"class":218,"line":236},[216,488,489],{"class":255},"    \"SELECT district, count(*) AS n, round(sum(area_m2)\u002F10000.0, 1) AS ha \"\n",[216,491,492],{"class":218,"line":243},[216,493,494],{"class":255},"    \"FROM survey.parcels GROUP BY district ORDER BY n DESC\"\n",[216,496,497],{"class":218,"line":262},[216,498,259],{"class":226},[216,500,501,504,507,510],{"class":218,"line":278},[216,502,503],{"class":222},"for",[216,505,506],{"class":226}," district, count, hectares ",[216,508,509],{"class":222},"in",[216,511,512],{"class":226}," rows:\n",[216,514,515,518,520,523,526,529,532,535,538,541,544,547,549,552,555,558,560,563],{"class":218,"line":283},[216,516,517],{"class":265},"    print",[216,519,269],{"class":226},[216,521,522],{"class":222},"f",[216,524,525],{"class":255},"\"",[216,527,528],{"class":265},"{",[216,530,531],{"class":226},"district",[216,533,534],{"class":222},":\u003C20",[216,536,537],{"class":265},"}",[216,539,540],{"class":265}," {",[216,542,543],{"class":226},"count",[216,545,546],{"class":222},":>6",[216,548,537],{"class":265},[216,550,551],{"class":265},"  {",[216,553,554],{"class":226},"hectares",[216,556,557],{"class":222},":>10",[216,559,537],{"class":265},[216,561,562],{"class":255}," ha\"",[216,564,259],{"class":226},[14,566,567,336,569,572,573,576],{},[184,568,335],{},[18,570,571],{},"executeSql()"," returns a list of lists — plain Python values, not features — so aggregate queries, counts and existence checks come back as ordinary data. There is no cursor and no streaming: the whole result is materialised, which is fine for a summary and a problem for a million rows. Column names are not returned, so the unpacking above depends on the ",[18,574,575],{},"SELECT"," order, and adding a column to the query silently shifts everything.",[14,578,579],{},"For statements that return nothing, the same call works and returns an empty list:",[207,581,583],{"className":209,"code":582,"language":211,"meta":212,"style":212},"connection.executeSql(\n    \"CREATE INDEX IF NOT EXISTS parcels_geom_idx \"\n    \"ON survey.parcels USING GIST (geom)\"\n)\nconnection.executeSql(\"ANALYZE survey.parcels\")\n",[18,584,585,590,595,600,604],{"__ignoreMap":212},[216,586,587],{"class":218,"line":219},[216,588,589],{"class":226},"connection.executeSql(\n",[216,591,592],{"class":218,"line":236},[216,593,594],{"class":255},"    \"CREATE INDEX IF NOT EXISTS parcels_geom_idx \"\n",[216,596,597],{"class":218,"line":243},[216,598,599],{"class":255},"    \"ON survey.parcels USING GIST (geom)\"\n",[216,601,602],{"class":218,"line":262},[216,603,259],{"class":226},[216,605,606,609,612],{"class":218,"line":278},[216,607,608],{"class":226},"connection.executeSql(",[216,610,611],{"class":255},"\"ANALYZE survey.parcels\"",[216,613,259],{"class":226},[14,615,616,336,618,621,622,625],{},[184,617,335],{},[18,619,620],{},"IF NOT EXISTS"," makes the statement idempotent, which matters for a script that may run repeatedly. ",[18,623,624],{},"ANALYZE"," after a bulk change is the step people forget: PostgreSQL's planner uses statistics that go stale after a large insert, and a query that was fast yesterday can choose a sequential scan today. Both of these are exactly the sort of maintenance the layer API cannot express.",[173,627,629],{"id":628},"parameterise-do-not-interpolate","Parameterise, do not interpolate",[14,631,632],{},"String-formatting a value into SQL is an injection risk and, more mundanely, breaks the first time a value contains an apostrophe.",[207,634,636],{"className":209,"code":635,"language":211,"meta":212,"style":212},"district = \"St Mary's\"\nrows = connection.executeSql(\n    \"SELECT count(*) FROM survey.parcels WHERE district = %s\",\n    [district],\n)\nprint(rows[0][0])\n",[18,637,638,648,656,669,674,678],{"__ignoreMap":212},[216,639,640,643,645],{"class":218,"line":219},[216,641,642],{"class":226},"district ",[216,644,249],{"class":222},[216,646,647],{"class":255}," \"St Mary's\"\n",[216,649,650,652,654],{"class":218,"line":236},[216,651,479],{"class":226},[216,653,249],{"class":222},[216,655,484],{"class":226},[216,657,658,661,664,666],{"class":218,"line":243},[216,659,660],{"class":255},"    \"SELECT count(*) FROM survey.parcels WHERE district = ",[216,662,663],{"class":265},"%s",[216,665,525],{"class":255},[216,667,668],{"class":226},",\n",[216,670,671],{"class":218,"line":262},[216,672,673],{"class":226},"    [district],\n",[216,675,676],{"class":218,"line":278},[216,677,259],{"class":226},[216,679,680,682,685,687,690,692],{"class":218,"line":283},[216,681,266],{"class":265},[216,683,684],{"class":226},"(rows[",[216,686,51],{"class":265},[216,688,689],{"class":226},"][",[216,691,51],{"class":265},[216,693,694],{"class":226},"])\n",[14,696,697,699,700,702,703,705,706,709],{},[184,698,335],{}," The second argument to ",[18,701,571],{}," is a list of parameters bound by the driver, using the provider's placeholder syntax — ",[18,704,663],{}," for PostgreSQL. The value never becomes part of the SQL text, so quoting, escaping and type conversion are the driver's problem. This is not merely a security nicety in a desktop script: a district named ",[18,707,708],{},"St Mary's"," breaks a naive f-string immediately, and the resulting error is a syntax error that points at the wrong thing.",[14,711,712],{},"Identifiers — table and column names — cannot be parameterised. Where they must be dynamic, validate against a list you control rather than against a pattern:",[207,714,716],{"className":209,"code":715,"language":211,"meta":212,"style":212},"ALLOWED = {\"parcels\", \"buildings\", \"roads\"}\nif table not in ALLOWED:\n    raise ValueError(f\"refusing to query unknown table {table!r}\")\n",[18,717,718,744,762],{"__ignoreMap":212},[216,719,720,723,726,728,731,733,736,738,741],{"class":218,"line":219},[216,721,722],{"class":265},"ALLOWED",[216,724,725],{"class":222}," =",[216,727,540],{"class":226},[216,729,730],{"class":255},"\"parcels\"",[216,732,401],{"class":226},[216,734,735],{"class":255},"\"buildings\"",[216,737,401],{"class":226},[216,739,740],{"class":255},"\"roads\"",[216,742,743],{"class":226},"}\n",[216,745,746,748,751,754,757,760],{"class":218,"line":236},[216,747,302],{"class":222},[216,749,750],{"class":226}," table ",[216,752,753],{"class":222},"not",[216,755,756],{"class":222}," in",[216,758,759],{"class":265}," ALLOWED",[216,761,314],{"class":226},[216,763,764,766,769,771,773,776,778,781,784,786,788],{"class":218,"line":243},[216,765,320],{"class":222},[216,767,768],{"class":265}," ValueError",[216,770,269],{"class":226},[216,772,522],{"class":222},[216,774,775],{"class":255},"\"refusing to query unknown table ",[216,777,528],{"class":265},[216,779,780],{"class":226},"table",[216,782,783],{"class":222},"!r",[216,785,537],{"class":265},[216,787,525],{"class":255},[216,789,259],{"class":226},[14,791,792,794,795,798],{},[184,793,335],{}," An allowlist is the only reliable defence for identifiers. Escaping them is possible with ",[18,796,797],{},"connection.tableUri()"," and quoting helpers, but a fixed set is simpler to reason about and covers the realistic cases.",[14,800,801],{},[34,802,805,808,811,814,817,824,830,837,842,847,851,855,858,861,865,868,871,874,877,880],{"viewBox":803,"role":37,"ariaLabel":804,"xmlns":39},"0 0 760 288","A value interpolated into SQL breaking on an apostrophe and allowing injection, compared with a bound parameter which the driver quotes correctly",[41,806,807],{},"Interpolation versus binding",[45,809,810],{},"An f-string places the value directly into the SQL text, so an apostrophe ends the literal early and the rest of the value is parsed as SQL. A bound parameter is sent separately from the statement, so the driver quotes it correctly and the value can never change the meaning of the query.",[49,812],{"x":51,"y":51,"width":52,"height":813,"fill":54},"288",[73,815,816],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"The apostrophe finds this bug before an attacker does",[49,818],{"x":819,"y":820,"width":821,"height":822,"rx":87,"fill":118,"stroke":823,"style":90},"26","52","330","204","#b91c1c",[73,825,829],{"x":826,"y":827,"style":828,"fill":823,"textAnchor":79},"191","80","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","interpolated",[49,831],{"x":832,"y":833,"width":834,"height":835,"rx":64,"fill":161,"stroke":823,"style":836},"50","98","282","46","stroke-width:1.6",[73,838,841],{"x":826,"y":839,"style":840,"fill":71,"textAnchor":79},"118","text-anchor:middle;font-size:9.5px;font-family:monospace","WHERE district = 'St Mary's'",[73,843,846],{"x":826,"y":844,"style":845,"fill":823,"textAnchor":79},"136","text-anchor:middle;font-size:9.5px;font-family:sans-serif","literal ends at the apostrophe",[73,848,850],{"x":826,"y":849,"style":104,"fill":71,"textAnchor":79},"176","syntax error at best",[73,852,854],{"x":826,"y":853,"style":104,"fill":71,"textAnchor":79},"200","arbitrary SQL at worst",[73,856,857],{"x":826,"y":85,"style":104,"fill":823,"textAnchor":79},"the value became code",[49,859],{"x":860,"y":820,"width":821,"height":822,"rx":87,"fill":138,"stroke":139,"style":90},"404",[73,862,864],{"x":863,"y":827,"style":828,"fill":139,"textAnchor":79},"569","bound",[49,866],{"x":867,"y":833,"width":834,"height":835,"rx":64,"fill":161,"stroke":139,"style":836},"428",[73,869,870],{"x":863,"y":839,"style":840,"fill":71,"textAnchor":79},"WHERE district = %s",[73,872,873],{"x":863,"y":844,"style":845,"fill":139,"textAnchor":79},"value sent separately",[73,875,876],{"x":863,"y":849,"style":104,"fill":71,"textAnchor":79},"driver quotes it correctly",[73,878,879],{"x":863,"y":853,"style":104,"fill":71,"textAnchor":79},"types converted for you",[73,881,882],{"x":863,"y":85,"style":104,"fill":139,"textAnchor":79},"the value stays a value",[173,884,886],{"id":885},"transactions","Transactions",[14,888,889],{},"Several statements that must succeed or fail together belong in a transaction, and the connection object supports one directly.",[207,891,893],{"className":209,"code":892,"language":211,"meta":212,"style":212},"connection.executeSql(\"BEGIN\")\ntry:\n    connection.executeSql(\n        \"UPDATE survey.parcels SET status = %s WHERE district = %s\",\n        [\"reviewed\", district],\n    )\n    connection.executeSql(\n        \"INSERT INTO survey.audit (action, district, at) VALUES (%s, %s, now())\",\n        [\"review\", district],\n    )\n    connection.executeSql(\"COMMIT\")\nexcept Exception:\n    connection.executeSql(\"ROLLBACK\")\n    raise\n",[18,894,895,904,911,916,932,943,948,952,968,978,983,994,1005,1015],{"__ignoreMap":212},[216,896,897,899,902],{"class":218,"line":219},[216,898,608],{"class":226},[216,900,901],{"class":255},"\"BEGIN\"",[216,903,259],{"class":226},[216,905,906,909],{"class":218,"line":236},[216,907,908],{"class":222},"try",[216,910,314],{"class":226},[216,912,913],{"class":218,"line":243},[216,914,915],{"class":226},"    connection.executeSql(\n",[216,917,918,921,923,926,928,930],{"class":218,"line":262},[216,919,920],{"class":255},"        \"UPDATE survey.parcels SET status = ",[216,922,663],{"class":265},[216,924,925],{"class":255}," WHERE district = ",[216,927,663],{"class":265},[216,929,525],{"class":255},[216,931,668],{"class":226},[216,933,934,937,940],{"class":218,"line":278},[216,935,936],{"class":226},"        [",[216,938,939],{"class":255},"\"reviewed\"",[216,941,942],{"class":226},", district],\n",[216,944,945],{"class":218,"line":283},[216,946,947],{"class":226},"    )\n",[216,949,950],{"class":218,"line":299},[216,951,915],{"class":226},[216,953,954,957,959,961,963,966],{"class":218,"line":317},[216,955,956],{"class":255},"        \"INSERT INTO survey.audit (action, district, at) VALUES (",[216,958,663],{"class":265},[216,960,401],{"class":255},[216,962,663],{"class":265},[216,964,965],{"class":255},", now())\"",[216,967,668],{"class":226},[216,969,971,973,976],{"class":218,"line":970},9,[216,972,936],{"class":226},[216,974,975],{"class":255},"\"review\"",[216,977,942],{"class":226},[216,979,981],{"class":218,"line":980},10,[216,982,947],{"class":226},[216,984,986,989,992],{"class":218,"line":985},11,[216,987,988],{"class":226},"    connection.executeSql(",[216,990,991],{"class":255},"\"COMMIT\"",[216,993,259],{"class":226},[216,995,997,1000,1003],{"class":218,"line":996},12,[216,998,999],{"class":222},"except",[216,1001,1002],{"class":265}," Exception",[216,1004,314],{"class":226},[216,1006,1008,1010,1013],{"class":218,"line":1007},13,[216,1009,988],{"class":226},[216,1011,1012],{"class":255},"\"ROLLBACK\"",[216,1014,259],{"class":226},[216,1016,1018],{"class":218,"line":1017},14,[216,1019,1020],{"class":222},"    raise\n",[14,1022,1023,1025,1026,401,1029,1032,1033,1036],{},[184,1024,335],{}," Issuing ",[18,1027,1028],{},"BEGIN",[18,1030,1031],{},"COMMIT"," and ",[18,1034,1035],{},"ROLLBACK"," as statements is the portable route and works on every provider that supports transactions. Re-raising after the rollback keeps the original traceback, which is what tells you which statement failed. The one thing to watch is that the connection object may pool underlying connections, so a long-lived transaction can behave unexpectedly if other code uses the same connection concurrently — keeping transactions short and self-contained avoids the question entirely.",[173,1038,1040],{"id":1039},"turn-a-query-into-a-layer","Turn a query into a layer",[14,1042,1043,1044,195],{},"When the result does have geometry and should appear on the map, a query layer is the route rather than ",[18,1045,1046],{},"executeSql",[207,1048,1050],{"className":209,"code":1049,"language":211,"meta":212,"style":212},"from qgis.core import QgsDataSourceUri, QgsVectorLayer, QgsProject\n\nuri = QgsDataSourceUri(connection.uri())\nuri.setDataSource(\n    \"\",\n    \"(SELECT p.gid, p.geom, d.name AS district_name \"\n    \" FROM survey.parcels p JOIN survey.districts d ON d.id = p.district_id)\",\n    \"geom\",\n    \"\",\n    \"gid\",\n)\nlayer = QgsVectorLayer(uri.uri(False), \"parcels with district\", \"postgres\")\nif not layer.isValid():\n    raise RuntimeError(\"query layer did not load — check the key column and geometry column\")\nQgsProject.instance().addMapLayer(layer)\n",[18,1051,1052,1063,1067,1076,1081,1088,1093,1100,1107,1113,1120,1124,1148,1158,1172],{"__ignoreMap":212},[216,1053,1054,1056,1058,1060],{"class":218,"line":219},[216,1055,223],{"class":222},[216,1057,227],{"class":226},[216,1059,230],{"class":222},[216,1061,1062],{"class":226}," QgsDataSourceUri, QgsVectorLayer, QgsProject\n",[216,1064,1065],{"class":218,"line":236},[216,1066,240],{"emptyLinePlaceholder":239},[216,1068,1069,1071,1073],{"class":218,"line":243},[216,1070,385],{"class":226},[216,1072,249],{"class":222},[216,1074,1075],{"class":226}," QgsDataSourceUri(connection.uri())\n",[216,1077,1078],{"class":218,"line":262},[216,1079,1080],{"class":226},"uri.setDataSource(\n",[216,1082,1083,1086],{"class":218,"line":278},[216,1084,1085],{"class":255},"    \"\"",[216,1087,668],{"class":226},[216,1089,1090],{"class":218,"line":283},[216,1091,1092],{"class":255},"    \"(SELECT p.gid, p.geom, d.name AS district_name \"\n",[216,1094,1095,1098],{"class":218,"line":299},[216,1096,1097],{"class":255},"    \" FROM survey.parcels p JOIN survey.districts d ON d.id = p.district_id)\"",[216,1099,668],{"class":226},[216,1101,1102,1105],{"class":218,"line":317},[216,1103,1104],{"class":255},"    \"geom\"",[216,1106,668],{"class":226},[216,1108,1109,1111],{"class":218,"line":970},[216,1110,1085],{"class":255},[216,1112,668],{"class":226},[216,1114,1115,1118],{"class":218,"line":980},[216,1116,1117],{"class":255},"    \"gid\"",[216,1119,668],{"class":226},[216,1121,1122],{"class":218,"line":985},[216,1123,259],{"class":226},[216,1125,1126,1129,1131,1134,1136,1139,1142,1144,1146],{"class":218,"line":996},[216,1127,1128],{"class":226},"layer ",[216,1130,249],{"class":222},[216,1132,1133],{"class":226}," QgsVectorLayer(uri.uri(",[216,1135,443],{"class":265},[216,1137,1138],{"class":226},"), ",[216,1140,1141],{"class":255},"\"parcels with district\"",[216,1143,401],{"class":226},[216,1145,256],{"class":255},[216,1147,259],{"class":226},[216,1149,1150,1152,1155],{"class":218,"line":1007},[216,1151,302],{"class":222},[216,1153,1154],{"class":222}," not",[216,1156,1157],{"class":226}," layer.isValid():\n",[216,1159,1160,1162,1165,1167,1170],{"class":218,"line":1017},[216,1161,320],{"class":222},[216,1163,1164],{"class":265}," RuntimeError",[216,1166,269],{"class":226},[216,1168,1169],{"class":255},"\"query layer did not load — check the key column and geometry column\"",[216,1171,259],{"class":226},[216,1173,1175],{"class":218,"line":1174},15,[216,1176,1177],{"class":226},"QgsProject.instance().addMapLayer(layer)\n",[14,1179,1180,1182,1183,195],{},[184,1181,335],{}," The subquery must be wrapped in parentheses and the key column named as the last argument — without a unique integer key the provider cannot identify features and the layer loads empty or misbehaves on selection. Including the geometry column explicitly avoids ambiguity when the join brings in a second one. The layer is read-only, and every pan re-runs the query with a bounding-box filter appended, so a query that is slow without a spatial index will be slow on every redraw; more on this in ",[26,1184,1186],{"href":1185},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis\u002F","loading a PostGIS query layer",[173,1188,1190],{"id":1189},"qgis-version-compatibility","QGIS version compatibility",[14,1192,1193,1194,1196],{},"The examples target ",[184,1195,186],{}," (Python 3.12).",[780,1198,1199,1215],{},[1200,1201,1202],"thead",{},[1203,1204,1205,1209,1212],"tr",{},[1206,1207,1208],"th",{},"QGIS version",[1206,1210,1211],{},"Python",[1206,1213,1214],{},"Notes",[1216,1217,1218,1233,1247,1263,1274],"tbody",{},[1203,1219,1220,1224,1227],{},[1221,1222,1223],"td",{},"3.16 LTR",[1221,1225,1226],{},"3.7",[1221,1228,1229,1230,1232],{},"Provider connection API with ",[18,1231,1046],{}," available for postgres.",[1203,1234,1235,1238,1241],{},[1221,1236,1237],{},"3.22 LTR",[1221,1239,1240],{},"3.9",[1221,1242,1243,1244,1246],{},"Bound parameters supported in ",[18,1245,1046],{}," for the postgres provider.",[1203,1248,1249,1252,1254],{},[1221,1250,1251],{},"3.28 LTR",[1221,1253,1240],{},[1221,1255,1256,1032,1259,1262],{},[18,1257,1258],{},"createConnection",[18,1260,1261],{},"findConnection"," stable on provider metadata.",[1203,1264,1265,1268,1271],{},[1221,1266,1267],{},"3.34 LTR",[1221,1269,1270],{},"3.12",[1221,1272,1273],{},"Baseline for this page.",[1203,1275,1276,1279,1281],{},[1221,1277,1278],{},"3.40+",[1221,1280,1270],{},[1221,1282,1283],{},"Connection API extended with schema and table introspection helpers.",[173,1285,1287],{"id":1286},"troubleshooting","Troubleshooting",[178,1289,1290,1301,1307,1313,1319,1333],{},[181,1291,1292,1297,1298,195],{},[184,1293,1294,1296],{},[18,1295,1261],{}," returns None."," The saved connection name differs. List ",[18,1299,1300],{},"metadata.connections().keys()",[181,1302,1303,1306],{},[184,1304,1305],{},"A query fails on some values."," The value was interpolated and contained a quote. Bind it as a parameter.",[181,1308,1309,1312],{},[184,1310,1311],{},"The query layer is empty."," No usable key column was declared, or the geometry column name is wrong.",[181,1314,1315,1318],{},[184,1316,1317],{},"Permission denied."," The connection's role lacks the privilege. DDL needs more than SELECT.",[181,1320,1321,336,1324,1326,1327,1032,1330,195],{},[184,1322,1323],{},"A big query exhausts memory.",[18,1325,1046],{}," materialises everything. Aggregate in SQL, or page with ",[18,1328,1329],{},"LIMIT",[18,1331,1332],{},"OFFSET",[181,1334,1335,1338,1339,195],{},[184,1336,1337],{},"A query got slower overnight."," Statistics are stale after a bulk change. Run ",[18,1340,624],{},[173,1342,1344],{"id":1343},"conclusion","Conclusion",[14,1346,1347,1348,1350],{},"Fetch the connection from the provider registry so credentials stay in one place, bind every value instead of interpolating it, allowlist any dynamic identifiers, and wrap multi-statement work in an explicit transaction with a rollback. Use ",[18,1349,1046],{}," for numbers and maintenance, and a query layer when the answer belongs on the map.",[173,1352,1354],{"id":1353},"frequently-asked-questions","Frequently Asked Questions",[14,1356,1357,1360,1361,1364],{},[184,1358,1359],{},"Should I use psycopg2 instead?","\nOnly if you need cursors, server-side streaming or ",[18,1362,1363],{},"COPY",". The provider connection reuses QGIS's stored credentials and needs no extra dependency, which for scripts running inside QGIS is a real advantage.",[14,1366,1367,1370,1371,1374,1375,1377],{},[184,1368,1369],{},"Does this work with GeoPackage?","\nYes — the same API with ",[18,1372,1373],{},"providerMetadata(\"ogr\")"," gives a connection to a GeoPackage, where ",[18,1376,1046],{}," runs SQLite SQL including the spatial functions. Placeholder syntax differs by provider.",[14,1379,1380,1383,1384,1386],{},[184,1381,1382],{},"Can I create a table and then load it as a layer?","\nYes, and it is a common pattern: run the DDL and the insert through the connection, then build a ",[18,1385,101],{}," on the new table. Nothing caches the schema, so the new table is visible immediately.",[14,1388,1389,1392,1393,1397,1398,1401],{},[184,1390,1391],{},"How do I append features efficiently?","\nFor bulk loading, ",[26,1394,1396],{"href":1395},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fappend-features-to-postgis-table-pyqgis\u002F","append features to a PostGIS table"," through the provider rather than generating ",[18,1399,1400],{},"INSERT"," statements — the provider batches, and it handles geometry encoding.",[173,1403,1405],{"id":1404},"related","Related",[178,1407,1408,1413,1418,1423,1428],{},[181,1409,1410,1412],{},[26,1411,29],{"href":28}," — the guide this recipe belongs to",[181,1414,1415],{},[26,1416,1417],{"href":193},"Connect to a PostGIS Database in PyQGIS",[181,1419,1420],{},[26,1421,1422],{"href":1185},"Load a PostGIS Query Layer in PyQGIS",[181,1424,1425],{},[26,1426,1427],{"href":1395},"Append Features to a PostGIS Table in PyQGIS",[181,1429,1430],{},[26,1431,1432],{"href":355},"Store Credentials with QgsAuthManager in PyQGIS",[1434,1435,1436],"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 .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":212,"searchDepth":236,"depth":236,"links":1438},[1439,1440,1441,1442,1443,1444,1445,1446,1447,1448,1449],{"id":175,"depth":236,"text":176},{"id":201,"depth":236,"text":202},{"id":465,"depth":236,"text":466},{"id":628,"depth":236,"text":629},{"id":885,"depth":236,"text":886},{"id":1039,"depth":236,"text":1040},{"id":1189,"depth":236,"text":1190},{"id":1286,"depth":236,"text":1287},{"id":1343,"depth":236,"text":1344},{"id":1353,"depth":236,"text":1354},{"id":1404,"depth":236,"text":1405},"Run arbitrary SQL against a PostGIS database from Python using QgsProviderRegistry connections — parameterised statements, transactions, DDL, and getting results back as a layer.","md",{"slug":1453,"type":1454,"breadcrumb":1455,"datePublished":1456,"dateModified":1456},"execute-sql-on-postgis-pyqgis","article","Execute SQL on PostGIS","2026-08-27","\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fexecute-sql-on-postgis-pyqgis",{"title":5,"description":1450},"spatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fexecute-sql-on-postgis-pyqgis\u002Findex","fDGnobVpZIQmgbIjtXX4Q0hnT2yOlmyZUTZ6zUYmwj4",1787823363745]