[{"data":1,"prerenderedAt":1488},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis":3},{"id":4,"title":5,"body":6,"description":1477,"extension":1478,"meta":1479,"navigation":233,"path":1484,"seo":1485,"stem":1486,"__hash__":1487},"docs\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis\u002Findex.md","Load a PostGIS Query Layer in PyQGIS",{"type":7,"value":8,"toc":1464},"minimark",[9,13,17,26,158,163,189,193,201,429,452,456,459,489,506,613,617,620,757,768,772,775,778,838,854,858,865,1105,1117,1124,1216,1220,1226,1297,1301,1369,1373,1379,1383,1389,1395,1407,1421,1427,1431,1460],[10,11,5],"h1",{"id":12},"load-a-postgis-query-layer-in-pyqgis",[14,15,16],"p",{},"A query layer is the shortest path between a question and a map. Instead of running three Processing algorithms to join, aggregate and filter, you write the SQL you would have written anyway and hand it to QGIS as a layer — stylable, labelable, exportable, and recalculated by the database every time it is drawn. It is the single biggest lever PostGIS gives a PyQGIS script, and the one most often left unused.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002F","PostGIS and Database Workflows in PyQGIS",". It covers the exact URI shape that turns SQL into a layer, the two declarations that stop QGIS running an expensive probe query, and when a materialised view is the better answer.",[14,27,28],{},[29,30,35,39,43,50,67,76,86,92,102,108,111,115,118,122,129,133,137,143,147,151,155],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 268","img","A processing chain of three algorithms writing intermediate files compared with a single SQL query layer answering the same question inside the database","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Three algorithms and two temporary files, or one query",[40,41,42],"desc",{},"The upper path runs a spatial join, then a statistics-by-category step, then a filter, writing two intermediate layers along the way and moving every feature into QGIS memory. The lower path expresses the same question as one SQL statement evaluated by PostGIS, which returns only the finished rows.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","268","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"qlArrow","0 0 10 10","8","5","7","auto-start-reverse",[63,64],"path",{"d":65,"fill":66},"M0 0 L10 5 L0 10 z","#2f3b35",[68,69,75],"text",{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Same answer, one round trip instead of three",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":82,"fill":83,"stroke":84,"style":85},"16","48","728","88","10","#fdf2e2","#b45309","stroke-width:2",[68,87,91],{"x":88,"y":89,"style":90,"fill":84,"textAnchor":74},"118","72","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","algorithm chain",[44,93],{"x":94,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":100,"style":101},"212","76","120","42","6","#fffdf7","#59645f","stroke-width:1.5",[68,103,107],{"x":104,"y":105,"style":106,"fill":66,"textAnchor":74},"272","102","text-anchor:middle;font-size:11px;font-family:sans-serif","spatial join",[44,109],{"x":110,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":100,"style":101},"352",[68,112,114],{"x":113,"y":105,"style":106,"fill":66,"textAnchor":74},"412","statistics",[44,116],{"x":117,"y":95,"width":96,"height":97,"rx":98,"fill":99,"stroke":100,"style":101},"492",[68,119,121],{"x":120,"y":105,"style":106,"fill":66,"textAnchor":74},"552","filter",[123,124],"line",{"x1":125,"y1":126,"x2":127,"y2":126,"stroke":66,"style":128},"332","97","346","stroke-width:2;marker-end:url(#qlArrow)",[123,130],{"x1":131,"y1":126,"x2":132,"y2":126,"stroke":66,"style":128},"472","486",[68,134,136],{"x":135,"y":105,"style":106,"fill":84,"textAnchor":74},"678","2 temp layers",[44,138],{"x":78,"y":139,"width":80,"height":81,"rx":82,"fill":140,"stroke":141,"style":142},"152","#edf8e9","#15803d","stroke-width:2.5",[68,144,146],{"x":88,"y":145,"style":90,"fill":141,"textAnchor":74},"176","query layer",[44,148],{"x":94,"y":149,"width":150,"height":97,"rx":98,"fill":99,"stroke":141,"style":101},"180","400",[68,152,154],{"x":113,"y":153,"style":106,"fill":66,"textAnchor":74},"206","SELECT … JOIN … GROUP BY … evaluated by PostGIS",[68,156,157],{"x":135,"y":153,"style":106,"fill":141,"textAnchor":74},"0 temp layers",[159,160,162],"h2",{"id":161},"prerequisites","Prerequisites",[164,165,166,179,182],"ul",{},[167,168,169,173,174,178],"li",{},[170,171,172],"strong",{},"QGIS 3.34 LTR"," or newer with a working PostGIS connection — see ",[21,175,177],{"href":176},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fconnect-to-postgis-database-pyqgis\u002F","Connect to a PostGIS Database in PyQGIS",".",[167,180,181],{},"Read access to the tables involved, and enough SQL to write the statement you want.",[167,183,184,185,188],{},"A column in the result that is ",[170,186,187],{},"unique across every row"," — without it the layer will misbehave in ways that look like data corruption.",[159,190,192],{"id":191},"the-subquery-form","The subquery form",[14,194,195,196,200],{},"The difference between a table layer and a query layer is one argument: pass an empty schema and put a parenthesised ",[197,198,199],"code",{},"SELECT"," where the table name goes.",[202,203,208],"pre",{"className":204,"code":205,"language":206,"meta":207,"style":207},"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\")\n\nsql = \"\"\"(\n    SELECT w.gid,\n           w.geom,\n           w.ward_name,\n           count(i.id)                       AS incidents,\n           count(i.id) \u002F (ST_Area(w.geom) \u002F 1000000.0) AS per_km2\n    FROM public.wards w\n    LEFT JOIN public.incidents i ON ST_Contains(w.geom, i.geom)\n    GROUP BY w.gid, w.geom, w.ward_name\n)\"\"\"\n\nuri.setDataSource(\"\", sql, \"geom\", \"\", \"gid\")\nlayer = QgsVectorLayer(uri.uri(False), \"Incidents per ward\", \"postgres\")\nprint(layer.isValid(), layer.featureCount())\n","python","",[197,209,210,228,235,247,281,292,297,308,314,320,326,332,338,344,350,356,362,367,392,420],{"__ignoreMap":207},[211,212,214,218,222,225],"span",{"class":123,"line":213},1,[211,215,217],{"class":216},"snl16","from",[211,219,221],{"class":220},"s95oV"," qgis.core ",[211,223,224],{"class":216},"import",[211,226,227],{"class":220}," QgsDataSourceUri, QgsVectorLayer\n",[211,229,231],{"class":123,"line":230},2,[211,232,234],{"emptyLinePlaceholder":233},true,"\n",[211,236,238,241,244],{"class":123,"line":237},3,[211,239,240],{"class":220},"uri ",[211,242,243],{"class":216},"=",[211,245,246],{"class":220}," QgsDataSourceUri()\n",[211,248,250,253,257,260,263,265,268,270,273,275,278],{"class":123,"line":249},4,[211,251,252],{"class":220},"uri.setConnection(",[211,254,256],{"class":255},"sU2Wk","\"db.example.org\"",[211,258,259],{"class":220},", ",[211,261,262],{"class":255},"\"5432\"",[211,264,259],{"class":220},[211,266,267],{"class":255},"\"cityworks\"",[211,269,259],{"class":220},[211,271,272],{"class":255},"\"gis_reader\"",[211,274,259],{"class":220},[211,276,277],{"class":255},"\"\"",[211,279,280],{"class":220},")\n",[211,282,284,287,290],{"class":123,"line":283},5,[211,285,286],{"class":220},"uri.setAuthConfigId(",[211,288,289],{"class":255},"\"a1b2c3d\"",[211,291,280],{"class":220},[211,293,295],{"class":123,"line":294},6,[211,296,234],{"emptyLinePlaceholder":233},[211,298,300,303,305],{"class":123,"line":299},7,[211,301,302],{"class":220},"sql ",[211,304,243],{"class":216},[211,306,307],{"class":255}," \"\"\"(\n",[211,309,311],{"class":123,"line":310},8,[211,312,313],{"class":255},"    SELECT w.gid,\n",[211,315,317],{"class":123,"line":316},9,[211,318,319],{"class":255},"           w.geom,\n",[211,321,323],{"class":123,"line":322},10,[211,324,325],{"class":255},"           w.ward_name,\n",[211,327,329],{"class":123,"line":328},11,[211,330,331],{"class":255},"           count(i.id)                       AS incidents,\n",[211,333,335],{"class":123,"line":334},12,[211,336,337],{"class":255},"           count(i.id) \u002F (ST_Area(w.geom) \u002F 1000000.0) AS per_km2\n",[211,339,341],{"class":123,"line":340},13,[211,342,343],{"class":255},"    FROM public.wards w\n",[211,345,347],{"class":123,"line":346},14,[211,348,349],{"class":255},"    LEFT JOIN public.incidents i ON ST_Contains(w.geom, i.geom)\n",[211,351,353],{"class":123,"line":352},15,[211,354,355],{"class":255},"    GROUP BY w.gid, w.geom, w.ward_name\n",[211,357,359],{"class":123,"line":358},16,[211,360,361],{"class":255},")\"\"\"\n",[211,363,365],{"class":123,"line":364},17,[211,366,234],{"emptyLinePlaceholder":233},[211,368,370,373,375,378,381,383,385,387,390],{"class":123,"line":369},18,[211,371,372],{"class":220},"uri.setDataSource(",[211,374,277],{"class":255},[211,376,377],{"class":220},", sql, ",[211,379,380],{"class":255},"\"geom\"",[211,382,259],{"class":220},[211,384,277],{"class":255},[211,386,259],{"class":220},[211,388,389],{"class":255},"\"gid\"",[211,391,280],{"class":220},[211,393,395,398,400,403,407,410,413,415,418],{"class":123,"line":394},19,[211,396,397],{"class":220},"layer ",[211,399,243],{"class":216},[211,401,402],{"class":220}," QgsVectorLayer(uri.uri(",[211,404,406],{"class":405},"sDLfK","False",[211,408,409],{"class":220},"), ",[211,411,412],{"class":255},"\"Incidents per ward\"",[211,414,259],{"class":220},[211,416,417],{"class":255},"\"postgres\"",[211,419,280],{"class":220},[211,421,423,426],{"class":123,"line":422},20,[211,424,425],{"class":405},"print",[211,427,428],{"class":220},"(layer.isValid(), layer.featureCount())\n",[14,430,431,434,435,438,439,442,443,446,447,451],{},[170,432,433],{},"Breakdown:"," The outer parentheses are required — they are how the provider recognises a subquery rather than a table name. ",[197,436,437],{},"gid"," is nominated as the key column and appears in the ",[197,440,441],{},"GROUP BY",", which guarantees one output row per ward and keeps the identifier unique. The density calculation divides by ",[197,444,445],{},"ST_Area"," in square metres, so the layer arrives ready to style with a ",[21,448,450],{"href":449},"\u002Fpyqgis-cartography-visualization\u002Fgraduated-categorized-renderers\u002F","graduated renderer"," with no further processing. Everything inside the parentheses runs on the server against its spatial index; QGIS receives finished rows.",[159,453,455],{"id":454},"declare-the-geometry-type-and-srid","Declare the geometry type and SRID",[14,457,458],{},"By default the provider does not know what geometry a subquery will produce or in which CRS, so it runs an extra query to find out — on a large table that probe can take longer than the query itself, and it runs before anything appears on screen.",[202,460,462],{"className":204,"code":461,"language":206,"meta":207,"style":207},"uri.setSrid(\"27700\")\nuri.setWkbType(QgsWkbTypes.MultiPolygon)\nuri.setUseEstimatedMetadata(True)\n",[197,463,464,474,479],{"__ignoreMap":207},[211,465,466,469,472],{"class":123,"line":213},[211,467,468],{"class":220},"uri.setSrid(",[211,470,471],{"class":255},"\"27700\"",[211,473,280],{"class":220},[211,475,476],{"class":123,"line":230},[211,477,478],{"class":220},"uri.setWkbType(QgsWkbTypes.MultiPolygon)\n",[211,480,481,484,487],{"class":123,"line":237},[211,482,483],{"class":220},"uri.setUseEstimatedMetadata(",[211,485,486],{"class":405},"True",[211,488,280],{"class":220},[14,490,491,493,494,497,498,501,502,505],{},[170,492,433],{}," ",[197,495,496],{},"setSrid()"," and ",[197,499,500],{},"setWkbType()"," tell the provider what to expect, so it skips the type-detection query entirely. ",[197,503,504],{},"setUseEstimatedMetadata(True)"," is the bigger win on large tables: instead of computing the exact extent with a full scan, the provider takes PostgreSQL's own statistics estimate. The extent is then approximate — fine for zooming, wrong for anything that measures — so leave it off when the extent feeds a calculation, and turn it on whenever a human is waiting for the canvas.",[14,507,508],{},[29,509,512,515,518,521,524,529,536,541,544,548,552,556,562,566,570,573,576,578,581,587,591,594,598,602,606,609],{"viewBox":510,"role":32,"ariaLabel":511,"xmlns":34},"0 0 760 250","Timeline comparison showing an undeclared query layer spending most of its load time on type detection and an exact extent scan, against a declared layer that skips both",[36,513,514],{},"Where the seconds go when a query layer loads",[40,516,517],{},"The upper timeline shows an undeclared query layer: a short connection step, a long geometry type detection step, a long exact extent scan, and finally the query itself. The lower timeline shows the same layer with SRID, geometry type and estimated metadata declared, where only the connection and the query remain.",[44,519],{"x":46,"y":46,"width":47,"height":520,"fill":49},"250",[68,522,523],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Two declarations remove most of the wait",[68,525,528],{"x":526,"y":89,"style":527,"fill":84},"24","font-size:12px;font-weight:bold;font-family:sans-serif","undeclared",[44,530],{"x":531,"y":532,"width":533,"height":534,"rx":535,"fill":99,"stroke":100,"style":101},"140","56","60","30","4",[68,537,540],{"x":538,"y":95,"style":539,"fill":66,"textAnchor":74},"170","text-anchor:middle;font-size:10px;font-family:sans-serif","connect",[44,542],{"x":543,"y":532,"width":149,"height":534,"rx":535,"fill":83,"stroke":84,"style":85},"204",[68,545,547],{"x":546,"y":95,"style":539,"fill":66,"textAnchor":74},"294","detect geometry type",[44,549],{"x":550,"y":532,"width":551,"height":534,"rx":535,"fill":83,"stroke":84,"style":85},"388","248",[68,553,555],{"x":554,"y":95,"style":539,"fill":66,"textAnchor":74},"512","exact extent scan over every row",[44,557],{"x":558,"y":532,"width":559,"height":534,"rx":535,"fill":560,"stroke":561,"style":101},"640","100","#eef7f4","#0f766e",[68,563,565],{"x":564,"y":95,"style":539,"fill":66,"textAnchor":74},"690","the query",[68,567,569],{"x":526,"y":568,"style":527,"fill":141},"146","declared",[44,571],{"x":531,"y":572,"width":533,"height":534,"rx":535,"fill":99,"stroke":100,"style":101},"130",[68,574,540],{"x":538,"y":575,"style":539,"fill":66,"textAnchor":74},"150",[44,577],{"x":543,"y":572,"width":559,"height":534,"rx":535,"fill":560,"stroke":561,"style":101},[68,579,565],{"x":580,"y":575,"style":539,"fill":66,"textAnchor":74},"254",[44,582],{"x":583,"y":584,"width":585,"height":586,"rx":98,"fill":140,"stroke":141,"style":85},"316","126","200","38",[68,588,590],{"x":589,"y":575,"style":106,"fill":141,"textAnchor":74},"416","setSrid + setWkbType",[44,592],{"x":593,"y":584,"width":94,"height":586,"rx":98,"fill":140,"stroke":141,"style":85},"528",[68,595,597],{"x":596,"y":575,"style":106,"fill":141,"textAnchor":74},"634","estimated metadata on",[123,599],{"x1":531,"y1":600,"x2":601,"y2":600,"stroke":100,"style":101},"196","740",[68,603,605],{"x":531,"y":604,"style":539,"fill":100,"textAnchor":74},"216","0 s",[68,607,608],{"x":601,"y":604,"style":539,"fill":100,"textAnchor":74},"first paint",[68,610,612],{"x":70,"y":611,"style":106,"fill":100,"textAnchor":74},"238","Estimated extents are approximate — turn them off when the extent feeds a measurement",[159,614,616],{"id":615},"parameterising-the-statement-safely","Parameterising the statement safely",[14,618,619],{},"Query layers are frequently built from user input — a date range, a selected category — and string formatting is the wrong tool for that job.",[202,621,623],{"className":204,"code":622,"language":206,"meta":207,"style":207},"from qgis.core import QgsProviderRegistry\n\nconnection = QgsProviderRegistry.instance().providerMetadata(\"postgres\").createConnection(\"cityworks\")\n\ncategory = \"flooding\"\nquoted = connection.quotedValue(category)          # returns 'flooding' with correct escaping\ncolumn = connection.quotedIdentifier(\"incident_type\")\n\nsql = f\"\"\"(\n    SELECT id, geom, reported_at\n    FROM public.incidents\n    WHERE {column} = {quoted}\n)\"\"\"\n",[197,624,625,636,640,659,663,673,687,702,706,718,723,728,753],{"__ignoreMap":207},[211,626,627,629,631,633],{"class":123,"line":213},[211,628,217],{"class":216},[211,630,221],{"class":220},[211,632,224],{"class":216},[211,634,635],{"class":220}," QgsProviderRegistry\n",[211,637,638],{"class":123,"line":230},[211,639,234],{"emptyLinePlaceholder":233},[211,641,642,645,647,650,652,655,657],{"class":123,"line":237},[211,643,644],{"class":220},"connection ",[211,646,243],{"class":216},[211,648,649],{"class":220}," QgsProviderRegistry.instance().providerMetadata(",[211,651,417],{"class":255},[211,653,654],{"class":220},").createConnection(",[211,656,267],{"class":255},[211,658,280],{"class":220},[211,660,661],{"class":123,"line":249},[211,662,234],{"emptyLinePlaceholder":233},[211,664,665,668,670],{"class":123,"line":283},[211,666,667],{"class":220},"category ",[211,669,243],{"class":216},[211,671,672],{"class":255}," \"flooding\"\n",[211,674,675,678,680,683],{"class":123,"line":294},[211,676,677],{"class":220},"quoted ",[211,679,243],{"class":216},[211,681,682],{"class":220}," connection.quotedValue(category)          ",[211,684,686],{"class":685},"sjoCn","# returns 'flooding' with correct escaping\n",[211,688,689,692,694,697,700],{"class":123,"line":299},[211,690,691],{"class":220},"column ",[211,693,243],{"class":216},[211,695,696],{"class":220}," connection.quotedIdentifier(",[211,698,699],{"class":255},"\"incident_type\"",[211,701,280],{"class":220},[211,703,704],{"class":123,"line":310},[211,705,234],{"emptyLinePlaceholder":233},[211,707,708,710,712,715],{"class":123,"line":316},[211,709,302],{"class":220},[211,711,243],{"class":216},[211,713,714],{"class":216}," f",[211,716,717],{"class":255},"\"\"\"(\n",[211,719,720],{"class":123,"line":322},[211,721,722],{"class":255},"    SELECT id, geom, reported_at\n",[211,724,725],{"class":123,"line":328},[211,726,727],{"class":255},"    FROM public.incidents\n",[211,729,730,733,736,739,742,745,747,750],{"class":123,"line":334},[211,731,732],{"class":255},"    WHERE ",[211,734,735],{"class":405},"{",[211,737,738],{"class":220},"column",[211,740,741],{"class":405},"}",[211,743,744],{"class":255}," = ",[211,746,735],{"class":405},[211,748,749],{"class":220},"quoted",[211,751,752],{"class":405},"}\n",[211,754,755],{"class":123,"line":340},[211,756,361],{"class":255},[14,758,759,493,761,497,764,767],{},[170,760,433],{},[197,762,763],{},"quotedValue()",[197,765,766],{},"quotedIdentifier()"," apply the database's own escaping rules — the same functions QGIS uses internally — so a value containing an apostrophe becomes a valid literal rather than a syntax error or an injection. Building the string with f-strings around those helpers is safe; building it with f-strings around raw user input is not. The provider does not support bind parameters in a layer URI, which is precisely why these helpers exist.",[159,769,771],{"id":770},"when-to-use-a-view-instead","When to use a view instead",[14,773,774],{},"A query layer re-runs its statement on every canvas refresh. That is exactly what you want for a cheap query over live data, and exactly what you do not want for a five-second aggregate that four people have open.",[14,776,777],{},"Move the statement into the database when it is expensive or shared:",[202,779,781],{"className":204,"code":780,"language":206,"meta":207,"style":207},"connection.executeSql(\"\"\"\n    CREATE MATERIALIZED VIEW public.incidents_per_ward AS\n    SELECT w.gid, w.geom, w.ward_name, count(i.id) AS incidents\n    FROM public.wards w\n    LEFT JOIN public.incidents i ON ST_Contains(w.geom, i.geom)\n    GROUP BY w.gid, w.geom, w.ward_name\n\"\"\")\nconnection.executeSql(\"CREATE UNIQUE INDEX ON public.incidents_per_ward (gid)\")\nconnection.executeSql(\"CREATE INDEX ON public.incidents_per_ward USING GIST (geom)\")\n",[197,782,783,791,796,801,805,809,813,820,829],{"__ignoreMap":207},[211,784,785,788],{"class":123,"line":213},[211,786,787],{"class":220},"connection.executeSql(",[211,789,790],{"class":255},"\"\"\"\n",[211,792,793],{"class":123,"line":230},[211,794,795],{"class":255},"    CREATE MATERIALIZED VIEW public.incidents_per_ward AS\n",[211,797,798],{"class":123,"line":237},[211,799,800],{"class":255},"    SELECT w.gid, w.geom, w.ward_name, count(i.id) AS incidents\n",[211,802,803],{"class":123,"line":249},[211,804,343],{"class":255},[211,806,807],{"class":123,"line":283},[211,808,349],{"class":255},[211,810,811],{"class":123,"line":294},[211,812,355],{"class":255},[211,814,815,818],{"class":123,"line":299},[211,816,817],{"class":255},"\"\"\"",[211,819,280],{"class":220},[211,821,822,824,827],{"class":123,"line":310},[211,823,787],{"class":220},[211,825,826],{"class":255},"\"CREATE UNIQUE INDEX ON public.incidents_per_ward (gid)\"",[211,828,280],{"class":220},[211,830,831,833,836],{"class":123,"line":316},[211,832,787],{"class":220},[211,834,835],{"class":255},"\"CREATE INDEX ON public.incidents_per_ward USING GIST (geom)\"",[211,837,280],{"class":220},[14,839,840,842,843,845,846,849,850,178],{},[170,841,433],{}," A materialised view stores the result, so the layer becomes an ordinary table layer with proper statistics and indexes. The unique index on ",[197,844,437],{}," gives QGIS its key column and lets PostgreSQL refresh the view concurrently; the GiST index on the geometry makes canvas panning fast. The cost is staleness — a scheduled ",[197,847,848],{},"REFRESH MATERIALIZED VIEW CONCURRENTLY"," decides how fresh the map is, which fits naturally into the ",[21,851,853],{"href":852},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fschedule-pyqgis-scripts-with-cron\u002F","scheduled job patterns",[159,855,857],{"id":856},"keep-the-sql-somewhere-you-can-review-it","Keep the SQL somewhere you can review it",[14,859,860,861,864],{},"A twenty-line statement embedded in a Python string is hard to read, impossible to run in ",[197,862,863],{},"psql"," without editing, and invisible to anyone reviewing the change. Keep the SQL in its own file and load it.",[202,866,868],{"className":204,"code":867,"language":206,"meta":207,"style":207},"from pathlib import Path\n\nSQL_DIR = Path(__file__).parent \u002F \"sql\"\n\ndef query_layer(uri, name, sql_file, **params):\n    statement = (SQL_DIR \u002F sql_file).read_text().strip().rstrip(\";\")\n    uri.setDataSource(\"\", f\"({statement.format(**params)})\", \"geom\", \"\", \"gid\")\n    layer = QgsVectorLayer(uri.uri(False), name, \"postgres\")\n    if not layer.isValid():\n        raise RuntimeError(f\"{name}: {layer.dataProvider().error().message()}\")\n    return layer\n\nwards = query_layer(uri, \"Incidents per ward\", \"incidents_per_ward.sql\", days=30)\n",[197,869,870,882,886,909,913,931,954,998,1016,1027,1064,1072,1076],{"__ignoreMap":207},[211,871,872,874,877,879],{"class":123,"line":213},[211,873,217],{"class":216},[211,875,876],{"class":220}," pathlib ",[211,878,224],{"class":216},[211,880,881],{"class":220}," Path\n",[211,883,884],{"class":123,"line":230},[211,885,234],{"emptyLinePlaceholder":233},[211,887,888,891,894,897,900,903,906],{"class":123,"line":237},[211,889,890],{"class":405},"SQL_DIR",[211,892,893],{"class":216}," =",[211,895,896],{"class":220}," Path(",[211,898,899],{"class":405},"__file__",[211,901,902],{"class":220},").parent ",[211,904,905],{"class":216},"\u002F",[211,907,908],{"class":255}," \"sql\"\n",[211,910,911],{"class":123,"line":249},[211,912,234],{"emptyLinePlaceholder":233},[211,914,915,918,922,925,928],{"class":123,"line":283},[211,916,917],{"class":216},"def",[211,919,921],{"class":920},"svObZ"," query_layer",[211,923,924],{"class":220},"(uri, name, sql_file, ",[211,926,927],{"class":216},"**",[211,929,930],{"class":220},"params):\n",[211,932,933,936,938,941,943,946,949,952],{"class":123,"line":294},[211,934,935],{"class":220},"    statement ",[211,937,243],{"class":216},[211,939,940],{"class":220}," (",[211,942,890],{"class":405},[211,944,945],{"class":216}," \u002F",[211,947,948],{"class":220}," sql_file).read_text().strip().rstrip(",[211,950,951],{"class":255},"\";\"",[211,953,280],{"class":220},[211,955,956,959,961,963,966,969,971,974,976,979,981,984,986,988,990,992,994,996],{"class":123,"line":299},[211,957,958],{"class":220},"    uri.setDataSource(",[211,960,277],{"class":255},[211,962,259],{"class":220},[211,964,965],{"class":216},"f",[211,967,968],{"class":255},"\"(",[211,970,735],{"class":405},[211,972,973],{"class":220},"statement.format(",[211,975,927],{"class":216},[211,977,978],{"class":220},"params)",[211,980,741],{"class":405},[211,982,983],{"class":255},")\"",[211,985,259],{"class":220},[211,987,380],{"class":255},[211,989,259],{"class":220},[211,991,277],{"class":255},[211,993,259],{"class":220},[211,995,389],{"class":255},[211,997,280],{"class":220},[211,999,1000,1003,1005,1007,1009,1012,1014],{"class":123,"line":310},[211,1001,1002],{"class":220},"    layer ",[211,1004,243],{"class":216},[211,1006,402],{"class":220},[211,1008,406],{"class":405},[211,1010,1011],{"class":220},"), name, ",[211,1013,417],{"class":255},[211,1015,280],{"class":220},[211,1017,1018,1021,1024],{"class":123,"line":316},[211,1019,1020],{"class":216},"    if",[211,1022,1023],{"class":216}," not",[211,1025,1026],{"class":220}," layer.isValid():\n",[211,1028,1029,1032,1035,1038,1040,1043,1045,1048,1050,1053,1055,1058,1060,1062],{"class":123,"line":322},[211,1030,1031],{"class":216},"        raise",[211,1033,1034],{"class":405}," RuntimeError",[211,1036,1037],{"class":220},"(",[211,1039,965],{"class":216},[211,1041,1042],{"class":255},"\"",[211,1044,735],{"class":405},[211,1046,1047],{"class":220},"name",[211,1049,741],{"class":405},[211,1051,1052],{"class":255},": ",[211,1054,735],{"class":405},[211,1056,1057],{"class":220},"layer.dataProvider().error().message()",[211,1059,741],{"class":405},[211,1061,1042],{"class":255},[211,1063,280],{"class":220},[211,1065,1066,1069],{"class":123,"line":328},[211,1067,1068],{"class":216},"    return",[211,1070,1071],{"class":220}," layer\n",[211,1073,1074],{"class":123,"line":334},[211,1075,234],{"emptyLinePlaceholder":233},[211,1077,1078,1081,1083,1086,1088,1090,1093,1095,1099,1101,1103],{"class":123,"line":340},[211,1079,1080],{"class":220},"wards ",[211,1082,243],{"class":216},[211,1084,1085],{"class":220}," query_layer(uri, ",[211,1087,412],{"class":255},[211,1089,259],{"class":220},[211,1091,1092],{"class":255},"\"incidents_per_ward.sql\"",[211,1094,259],{"class":220},[211,1096,1098],{"class":1097},"s9osk","days",[211,1100,243],{"class":216},[211,1102,534],{"class":405},[211,1104,280],{"class":220},[14,1106,1107,1109,1110,1113,1114,1116],{},[170,1108,433],{}," Reading the file at call time means the statement can be opened in a database client, explained, and profiled as ordinary SQL — the thing you actually want when a query layer is slow. Stripping a trailing semicolon matters because the provider wraps the statement in its own parentheses and a semicolon inside them is a syntax error. ",[197,1111,1112],{},"format(**params)"," handles simple substitutions such as a day count; anything derived from user input still has to go through the connection's ",[197,1115,763],{}," helper rather than straight into the template.",[14,1118,1119,1120,178],{},"The same files then serve three purposes: they are what the query layer runs, what a materialised view is created from, and what a reviewer reads in a pull request. Placing them beside the plugin or the scripts, under version control, is the cheapest way to stop a project's real logic living in string literals — the same instinct behind keeping styles as files in ",[21,1121,1123],{"href":1122},"\u002Fpyqgis-cartography-visualization\u002Fprogrammatic-layer-styling\u002Fsave-and-load-qml-style-pyqgis\u002F","Save and Load a QML Style in PyQGIS",[14,1125,1126],{},[29,1127,1130,1133,1136,1139,1146,1149,1154,1158,1162,1165,1170,1174,1178,1181,1185,1190,1194,1197,1202,1204,1206,1212],{"viewBox":1128,"role":32,"ariaLabel":1129,"xmlns":34},"0 0 760 244","One SQL file feeding three consumers: a query layer in QGIS, a materialised view in the database, and a code review, compared with the same SQL trapped inside a Python string",[36,1131,1132],{},"One SQL file, three consumers",[40,1134,1135],{},"A statement kept in its own file is loaded by the query layer, used to create a materialised view, and read directly during code review. The same statement embedded in a Python string literal can only be used by the code that contains it, and cannot be run in a database client without editing.",[44,1137],{"x":46,"y":46,"width":47,"height":1138,"fill":49},"244",[51,1140,1141],{},[54,1142,1144],{"id":1143,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"qlSqlArrow",[63,1145],{"d":65,"fill":66},[68,1147,1148],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"A statement in a file can be explained; one in a string cannot",[44,1150],{"x":1151,"y":81,"width":1152,"height":1153,"rx":58,"fill":140,"stroke":141,"style":142},"20","188","64",[68,1155,1157],{"x":1156,"y":1156,"style":90,"fill":141,"textAnchor":74},"114","incidents_per_ward.sql",[68,1159,1161],{"x":1156,"y":1160,"style":106,"fill":66,"textAnchor":74},"134","under version control",[44,1163],{"x":1164,"y":79,"width":94,"height":79,"rx":58,"fill":99,"stroke":561,"style":85},"288",[68,1166,1169],{"x":1167,"y":1168,"style":106,"fill":66,"textAnchor":74},"394","78","a query layer in QGIS",[44,1171],{"x":1164,"y":1172,"width":94,"height":79,"rx":58,"fill":99,"stroke":1173,"style":85},"106","#2563eb",[68,1175,1177],{"x":1167,"y":1176,"style":106,"fill":66,"textAnchor":74},"136","a materialised view",[44,1179],{"x":1164,"y":1180,"width":94,"height":79,"rx":58,"fill":99,"stroke":84,"style":85},"164",[68,1182,1184],{"x":1167,"y":1183,"style":106,"fill":66,"textAnchor":74},"194","EXPLAIN ANALYZE in psql",[44,1186],{"x":1187,"y":81,"width":1188,"height":1153,"rx":58,"fill":83,"stroke":1189,"style":142},"556","184","#b91c1c",[68,1191,1193],{"x":1192,"y":1156,"style":90,"fill":1189,"textAnchor":74},"648","sql = \"\"\"…\"\"\"",[68,1195,1196],{"x":1192,"y":1160,"style":106,"fill":66,"textAnchor":74},"reachable only from here",[123,1198],{"x1":1199,"y1":1172,"x2":1200,"y2":95,"stroke":66,"style":1201},"208","282","stroke-width:2;marker-end:url(#qlSqlArrow)",[123,1203],{"x1":1199,"y1":96,"x2":1200,"y2":572,"stroke":66,"style":1201},[123,1205],{"x1":1199,"y1":1176,"x2":1200,"y2":1188,"stroke":66,"style":1201},[123,1207],{"x1":1208,"y1":96,"x2":1209,"y2":1210,"stroke":1189,"style":1211},"550","506","80","stroke-width:2;stroke-dasharray:5 4;marker-end:url(#qlSqlArrow)",[68,1213,1215],{"x":70,"y":1214,"style":106,"fill":100,"textAnchor":74},"232","Interpolate values through quotedValue(), never straight into the template",[159,1217,1219],{"id":1218},"qgis-version-compatibility","QGIS version compatibility",[14,1221,1222,1223,1225],{},"The examples target ",[170,1224,172],{}," (Python 3.12).",[1227,1228,1229,1245],"table",{},[1230,1231,1232],"thead",{},[1233,1234,1235,1239,1242],"tr",{},[1236,1237,1238],"th",{},"QGIS version",[1236,1240,1241],{},"Python",[1236,1243,1244],{},"Notes",[1246,1247,1248,1266,1276,1287],"tbody",{},[1233,1249,1250,1254,1257],{},[1251,1252,1253],"td",{},"3.22 LTR",[1251,1255,1256],{},"3.9",[1251,1258,1259,1260,1262,1263,178],{},"Subquery layers work identically; ",[197,1261,763],{}," on the connection object is unavailable, so escape with ",[197,1264,1265],{},"QgsExpression.quotedValue()",[1233,1267,1268,1271,1273],{},[1251,1269,1270],{},"3.28 LTR",[1251,1272,1256],{},[1251,1274,1275],{},"Connection helpers available; behaviour matches this page.",[1233,1277,1278,1281,1284],{},[1251,1279,1280],{},"3.34 LTR",[1251,1282,1283],{},"3.12",[1251,1285,1286],{},"Baseline for this page.",[1233,1288,1289,1292,1294],{},[1251,1290,1291],{},"3.40 \u002F 3.44",[1251,1293,1283],{},[1251,1295,1296],{},"The SQL query dialog gained saved queries; the Python API is unchanged.",[159,1298,1300],{"id":1299},"troubleshooting","Troubleshooting",[164,1302,1303,1312,1324,1330,1342,1363],{},[167,1304,1305,1308,1309,1311],{},[170,1306,1307],{},"Every feature has the same identifier, or features vanish when you zoom."," The key column is not unique across the result. A join that multiplies rows is the usual cause; add the joined table's key to the ",[197,1310,199],{}," and nominate a composite that is genuinely unique, or aggregate the duplicates away.",[167,1313,1314,1317,1318,1321,1322,178],{},[170,1315,1316],{},"The layer is invalid and the message mentions the geometry column."," The subquery does not expose a column with that name, or the name is aliased. Alias the geometry explicitly as ",[197,1319,1320],{},"geom"," and pass ",[197,1323,1320],{},[167,1325,1326,1329],{},[170,1327,1328],{},"Loading takes many seconds before anything draws."," The extent probe is running. Declare the SRID and geometry type, and enable estimated metadata.",[167,1331,1332,1335,1336,1338,1339,178],{},[170,1333,1334],{},"Attributes are all strings."," PostgreSQL could not infer types through the subquery — cast them explicitly in the ",[197,1337,199],{},", for example ",[197,1340,1341],{},"count(i.id)::int",[167,1343,1344,1347,1348,1351,1352,1355,1356,1359,1360,178],{},[170,1345,1346],{},"A syntax error appears only when a filter is applied."," QGIS wraps the subquery in its own ",[197,1349,1350],{},"WHERE",", so a statement ending in ",[197,1353,1354],{},"LIMIT"," or ",[197,1357,1358],{},"ORDER BY"," can become invalid. Wrap yours in an extra ",[197,1361,1362],{},"SELECT * FROM ( … ) AS q",[167,1364,1365,1368],{},[170,1366,1367],{},"The query is correct but returns nothing through QGIS."," Check the role: a query layer runs as the connecting user, and row-level security policies apply to it.",[159,1370,1372],{"id":1371},"conclusion","Conclusion",[14,1374,1375,1376,1378],{},"A query layer is an ordinary layer whose table happens to be a parenthesised ",[197,1377,199],{},". Give it a genuinely unique key column, declare the SRID and geometry type so the provider skips its probe, escape any interpolated values with the connection's quoting helpers, and promote the statement to a materialised view once it becomes expensive or widely used.",[159,1380,1382],{"id":1381},"frequently-asked-questions","Frequently Asked Questions",[14,1384,1385,1388],{},[170,1386,1387],{},"Can a query layer be edited?","\nNo. Anything built from a subquery is read-only, because the provider cannot know how to map an edit back to the underlying rows. Edit the source tables and let the query reflect the change.",[14,1390,1391,1394],{},[170,1392,1393],{},"Does the query run once or on every redraw?","\nOn every redraw, filtered by the current canvas extent. That is why declaring the geometry type matters and why an expensive statement belongs in a materialised view.",[14,1396,1397,1400,1401,1404,1405,178],{},[170,1398,1399],{},"Can I use a CTE or a window function?","\nYes — anything valid inside a subquery works, including ",[197,1402,1403],{},"WITH"," clauses and window functions. Wrap the whole thing so the outer statement is a single ",[197,1406,199],{},[14,1408,1409,1412,1413,1416,1417,178],{},[170,1410,1411],{},"How do I combine a query layer with a Processing algorithm?","\nPass the layer object straight into ",[197,1414,1415],{},"processing.run()",". Algorithms accept it exactly as they accept a file-based layer, and the provider streams the result rows in — see ",[21,1418,1420],{"href":1419},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002Frun-processing-algorithm-from-script\u002F","Run a Processing Algorithm from a Script",[14,1422,1423,1426],{},[170,1424,1425],{},"Is a query layer slower than a table layer?","\nFor a simple filter, no — the planner handles it much as it would a view. For a multi-table aggregate, it is exactly as slow as the query, every time the canvas refreshes, which is the argument for materialising it.",[159,1428,1430],{"id":1429},"related","Related",[164,1432,1433,1438,1442,1448,1454],{},[167,1434,1435,1437],{},[21,1436,24],{"href":23}," — the guide this recipe belongs to",[167,1439,1440],{},[21,1441,177],{"href":176},[167,1443,1444],{},[21,1445,1447],{"href":1446},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fwrite-vector-layer-to-geopackage-pyqgis\u002F","Write a Vector Layer to GeoPackage in PyQGIS",[167,1449,1450],{},[21,1451,1453],{"href":1452},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002Fselect-features-by-expression-pyqgis\u002F","Select Features by Expression in PyQGIS",[167,1455,1456],{},[21,1457,1459],{"href":1458},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis\u002F","Join Attributes by Field Value in PyQGIS",[1461,1462,1463],"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);}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":207,"searchDepth":230,"depth":230,"links":1465},[1466,1467,1468,1469,1470,1471,1472,1473,1474,1475,1476],{"id":161,"depth":230,"text":162},{"id":191,"depth":230,"text":192},{"id":454,"depth":230,"text":455},{"id":615,"depth":230,"text":616},{"id":770,"depth":230,"text":771},{"id":856,"depth":230,"text":857},{"id":1218,"depth":230,"text":1219},{"id":1299,"depth":230,"text":1300},{"id":1371,"depth":230,"text":1372},{"id":1381,"depth":230,"text":1382},{"id":1429,"depth":230,"text":1430},"Back a QGIS layer with an arbitrary SQL statement — joins, aggregates and ST_ functions — by passing a subquery to QgsDataSourceUri, and avoid the slow extent probe that makes query layers feel sluggish.","md",{"slug":1480,"type":1481,"breadcrumb":1482,"datePublished":1483,"dateModified":1483},"load-postgis-query-layer-pyqgis","article","PostGIS Query Layers","2026-08-10","\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis",{"title":5,"description":1477},"spatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis\u002Findex","FnpmU5DewKZu87QDGadU6kNpisB62d6pqKFnwulyUBM",1786401338512]