[{"data":1,"prerenderedAt":1508},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fquery-layers-with-virtual-layer-sql-pyqgis":3},{"id":4,"title":5,"body":6,"description":1497,"extension":1498,"meta":1499,"navigation":270,"path":1504,"seo":1505,"stem":1506,"__hash__":1507},"docs\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fquery-layers-with-virtual-layer-sql-pyqgis\u002Findex.md","Query Layers with Virtual Layer SQL in PyQGIS",{"type":7,"value":8,"toc":1484},"minimark",[9,13,17,26,200,205,227,231,238,482,508,515,592,596,603,770,784,788,799,892,991,1012,1016,1019,1151,1179,1182,1186,1189,1305,1331,1335,1360,1364,1405,1409,1418,1422,1428,1434,1440,1448,1452,1480],[10,11,5],"h1",{"id":12},"query-layers-with-virtual-layer-sql-in-pyqgis",[14,15,16],"p",{},"A virtual layer is a SQL view over other layers. The layers can be anything QGIS opens — a shapefile, a CSV, a PostGIS table, a WFS — and the query runs in an embedded SQLite engine with SpatiaLite functions, so you can join a spreadsheet to a GeoPackage and filter the result by a buffer in one statement without first copying everything into a database. The result is a live layer: change the underlying data and the view changes with it.",[14,18,19,20,25],{},"That convenience has a cost, and knowing where it starts is most of the skill. This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002F","Layer Data Sources & Formats in PyQGIS",". It builds virtual layers from Python, covers geometry and id declarations, shows the spatial index trick that keeps joins fast, and compares the alternatives.",[14,27,28],{},[29,30,35,39,43,50,67,76,85,91,96,101,105,109,114,118,122,130,133,137,147,153,158,162,165,169,173,181,187,191,195],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 300","img","A virtual layer reading three different providers: a GeoPackage table, a CSV and a PostGIS table all feed an embedded SQLite engine that runs one SQL query and exposes the result as a layer","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"One query across three providers",[40,41,42],"desc",{},"On the left, three sources: wards from a GeoPackage, readings from a CSV, and stations from PostGIS. Each is registered as a named table in the virtual layer definition. An embedded SQLite and SpatiaLite engine in the middle runs a SELECT with a JOIN and a spatial predicate. On the right the result is a read-only layer that updates when any source changes.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","300","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"vlSrcArrow","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","SQL over layers that live in different places",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":58,"fill":82,"stroke":83,"style":84},"24","54","186","56","#eef7f4","#0f766e","stroke-width:2",[68,86,90],{"x":87,"y":88,"style":89,"fill":83,"textAnchor":74},"117","78","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","wards",[68,92,95],{"x":87,"y":93,"style":94,"fill":66,"textAnchor":74},"98","text-anchor:middle;font-size:9.5px;font-family:sans-serif","GeoPackage · polygons",[44,97],{"x":78,"y":98,"width":80,"height":81,"rx":58,"fill":99,"stroke":100,"style":84},"126","#fdf2e2","#b45309",[68,102,104],{"x":87,"y":103,"style":89,"fill":100,"textAnchor":74},"150","readings",[68,106,108],{"x":87,"y":107,"style":94,"fill":66,"textAnchor":74},"170","CSV · no geometry",[44,110],{"x":78,"y":111,"width":80,"height":81,"rx":58,"fill":112,"stroke":113,"style":84},"198","#eff3ff","#2563eb",[68,115,117],{"x":87,"y":116,"style":89,"fill":113,"textAnchor":74},"222","stations",[68,119,121],{"x":87,"y":120,"style":94,"fill":66,"textAnchor":74},"242","PostGIS · points",[123,124],"line",{"x1":125,"y1":126,"x2":127,"y2":128,"stroke":66,"style":129},"210","82","270","130","stroke-width:1.8;marker-end:url(#vlSrcArrow)",[123,131],{"x1":125,"y1":132,"x2":127,"y2":132,"stroke":66,"style":129},"154",[123,134],{"x1":125,"y1":135,"x2":127,"y2":136,"stroke":66,"style":129},"226","178",[44,138],{"x":139,"y":140,"width":141,"height":142,"rx":143,"fill":144,"stroke":145,"style":146},"278","90","236","128","10","#fffdf7","#59645f","stroke-width:2.5",[68,148,152],{"x":149,"y":150,"style":151,"fill":73,"textAnchor":74},"396","116","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","SQLite + SpatiaLite",[68,154,157],{"x":149,"y":155,"style":156,"fill":66,"textAnchor":74},"142","text-anchor:middle;font-size:9.5px;font-family:monospace","SELECT w.name, avg(r.pm25)",[68,159,161],{"x":149,"y":160,"style":156,"fill":66,"textAnchor":74},"160","JOIN … ON r.station = s.id",[68,163,164],{"x":149,"y":136,"style":156,"fill":66,"textAnchor":74},"WHERE ST_Within(s.geom, …)",[68,166,168],{"x":149,"y":167,"style":94,"fill":145,"textAnchor":74},"202","runs when the layer is read",[123,170],{"x1":171,"y1":132,"x2":172,"y2":132,"stroke":66,"style":129},"514","560",[44,174],{"x":175,"y":176,"width":177,"height":178,"rx":143,"fill":179,"stroke":180,"style":146},"568","104","168","100","#e8efe6","#15803d",[68,182,186],{"x":183,"y":184,"style":151,"fill":185,"textAnchor":74},"652","132","#166534","virtual layer",[68,188,190],{"x":183,"y":189,"style":94,"fill":66,"textAnchor":74},"156","read-only view",[68,192,194],{"x":183,"y":193,"style":94,"fill":66,"textAnchor":74},"176","live: follows sources",[68,196,199],{"x":70,"y":197,"style":198,"fill":145,"textAnchor":74},"280","text-anchor:middle;font-size:10.5px;font-family:sans-serif","each source is read through its own provider — nothing is copied up front",[201,202,204],"h2",{"id":203},"prerequisites","Prerequisites",[206,207,208,216,219],"ul",{},[209,210,211,215],"li",{},[212,213,214],"strong",{},"QGIS 3.40 LTR"," or newer, or the QGIS 4 series.",[209,217,218],{},"The layers you want to query, loaded in the project or reachable by path.",[209,220,221,222,226],{},"Familiarity with basic SQL. The dialect is SQLite, with SpatiaLite's ",[223,224,225],"code",{},"ST_"," functions for geometry.",[201,228,230],{"id":229},"build-a-virtual-layer-from-project-layers","Build a virtual layer from project layers",[14,232,233,234,237],{},"Layers already in the project can be referenced by name directly in the query — the provider finds them. Building the definition with ",[223,235,236],{},"QgsVirtualLayerDefinition"," rather than hand-writing the URI keeps quoting and encoding correct.",[239,240,245],"pre",{"className":241,"code":242,"language":243,"meta":244,"style":244},"language-python shiki shiki-themes github-dark","from qgis.core import Qgis, QgsProject, QgsVectorLayer, QgsVirtualLayerDefinition\n\nsql = \"\"\"\nSELECT w.fid AS ward_id,\n       w.ward_code,\n       w.ward_name,\n       count(s.station_id) AS stations,\n       w.geometry\nFROM wards AS w\nLEFT JOIN stations AS s\n       ON ST_Within(s.geometry, w.geometry)\nGROUP BY w.fid\n\"\"\"\n\ndefinition = QgsVirtualLayerDefinition()\ndefinition.setQuery(sql)\ndefinition.setUid(\"ward_id\")\ndefinition.setGeometryField(\"geometry\")\ndefinition.setGeometryWkbType(Qgis.WkbType.Polygon)\ndefinition.setGeometrySrid(27700)\n\nsummary = QgsVectorLayer(definition.toString(), \"stations per ward\", \"virtual\")\nif not summary.isValid():\n    raise RuntimeError(summary.dataProvider().error().message()\n                       if summary.dataProvider() else \"invalid virtual layer\")\nQgsProject.instance().addMapLayer(summary)\n","python","",[223,246,247,265,272,285,291,297,303,309,315,321,327,333,339,345,350,361,367,379,390,396,408,413,435,447,459,476],{"__ignoreMap":244},[248,249,251,255,259,262],"span",{"class":123,"line":250},1,[248,252,254],{"class":253},"snl16","from",[248,256,258],{"class":257},"s95oV"," qgis.core ",[248,260,261],{"class":253},"import",[248,263,264],{"class":257}," Qgis, QgsProject, QgsVectorLayer, QgsVirtualLayerDefinition\n",[248,266,268],{"class":123,"line":267},2,[248,269,271],{"emptyLinePlaceholder":270},true,"\n",[248,273,275,278,281],{"class":123,"line":274},3,[248,276,277],{"class":257},"sql ",[248,279,280],{"class":253},"=",[248,282,284],{"class":283},"sU2Wk"," \"\"\"\n",[248,286,288],{"class":123,"line":287},4,[248,289,290],{"class":283},"SELECT w.fid AS ward_id,\n",[248,292,294],{"class":123,"line":293},5,[248,295,296],{"class":283},"       w.ward_code,\n",[248,298,300],{"class":123,"line":299},6,[248,301,302],{"class":283},"       w.ward_name,\n",[248,304,306],{"class":123,"line":305},7,[248,307,308],{"class":283},"       count(s.station_id) AS stations,\n",[248,310,312],{"class":123,"line":311},8,[248,313,314],{"class":283},"       w.geometry\n",[248,316,318],{"class":123,"line":317},9,[248,319,320],{"class":283},"FROM wards AS w\n",[248,322,324],{"class":123,"line":323},10,[248,325,326],{"class":283},"LEFT JOIN stations AS s\n",[248,328,330],{"class":123,"line":329},11,[248,331,332],{"class":283},"       ON ST_Within(s.geometry, w.geometry)\n",[248,334,336],{"class":123,"line":335},12,[248,337,338],{"class":283},"GROUP BY w.fid\n",[248,340,342],{"class":123,"line":341},13,[248,343,344],{"class":283},"\"\"\"\n",[248,346,348],{"class":123,"line":347},14,[248,349,271],{"emptyLinePlaceholder":270},[248,351,353,356,358],{"class":123,"line":352},15,[248,354,355],{"class":257},"definition ",[248,357,280],{"class":253},[248,359,360],{"class":257}," QgsVirtualLayerDefinition()\n",[248,362,364],{"class":123,"line":363},16,[248,365,366],{"class":257},"definition.setQuery(sql)\n",[248,368,370,373,376],{"class":123,"line":369},17,[248,371,372],{"class":257},"definition.setUid(",[248,374,375],{"class":283},"\"ward_id\"",[248,377,378],{"class":257},")\n",[248,380,382,385,388],{"class":123,"line":381},18,[248,383,384],{"class":257},"definition.setGeometryField(",[248,386,387],{"class":283},"\"geometry\"",[248,389,378],{"class":257},[248,391,393],{"class":123,"line":392},19,[248,394,395],{"class":257},"definition.setGeometryWkbType(Qgis.WkbType.Polygon)\n",[248,397,399,402,406],{"class":123,"line":398},20,[248,400,401],{"class":257},"definition.setGeometrySrid(",[248,403,405],{"class":404},"sDLfK","27700",[248,407,378],{"class":257},[248,409,411],{"class":123,"line":410},21,[248,412,271],{"emptyLinePlaceholder":270},[248,414,416,419,421,424,427,430,433],{"class":123,"line":415},22,[248,417,418],{"class":257},"summary ",[248,420,280],{"class":253},[248,422,423],{"class":257}," QgsVectorLayer(definition.toString(), ",[248,425,426],{"class":283},"\"stations per ward\"",[248,428,429],{"class":257},", ",[248,431,432],{"class":283},"\"virtual\"",[248,434,378],{"class":257},[248,436,438,441,444],{"class":123,"line":437},23,[248,439,440],{"class":253},"if",[248,442,443],{"class":253}," not",[248,445,446],{"class":257}," summary.isValid():\n",[248,448,450,453,456],{"class":123,"line":449},24,[248,451,452],{"class":253},"    raise",[248,454,455],{"class":404}," RuntimeError",[248,457,458],{"class":257},"(summary.dataProvider().error().message()\n",[248,460,462,465,468,471,474],{"class":123,"line":461},25,[248,463,464],{"class":253},"                       if",[248,466,467],{"class":257}," summary.dataProvider() ",[248,469,470],{"class":253},"else",[248,472,473],{"class":283}," \"invalid virtual layer\"",[248,475,378],{"class":257},[248,477,479],{"class":123,"line":478},26,[248,480,481],{"class":257},"QgsProject.instance().addMapLayer(summary)\n",[14,483,484,487,488,491,492,495,496,499,500,503,504,507],{},[212,485,486],{},"Breakdown:"," Table names in the ",[223,489,490],{},"FROM"," clause are layer names from the project; if a name has spaces, quote it in double quotes. The geometry column of a referenced layer is exposed as ",[223,493,494],{},"geometry"," whatever the source calls it. Declaring the uid, geometry field, type and SRID is optional — the provider will otherwise inspect the query result to guess — but guessing needs to run the query once just to open the layer, and a grouped or joined query can make that noticeably slow. ",[223,497,498],{},"setUid"," must name a column with unique integer values — hence the ",[223,501,502],{},"fid AS ward_id"," alias, since a ward code like ",[223,505,506],{},"E05001234"," is text, which is what gives every feature a stable id; without one, feature ids are row numbers and change whenever the result order changes.",[14,509,510,511,514],{},"On releases before 3.30 the WKB type constant is spelled ",[223,512,513],{},"QgsWkbTypes.Polygon","; everything else in the definition is unchanged.",[14,516,517],{},[29,518,521,524,527,530,533,539,545,551,555,559,563,568,571,575,580,583,586,589],{"viewBox":519,"role":32,"ariaLabel":520,"xmlns":34},"0 0 760 276","What the virtual layer needs declared: without a uid features renumber on every read, without geometry declarations the provider runs the query once just to open the layer",[36,522,523],{},"Declare what you can, so nothing is guessed",[40,525,526],{},"Two panels. Undeclared: the provider runs the query at open time to discover the geometry column, type and CRS, and feature ids are row positions that change when results reorder, which breaks selections and edits. Declared: setUid gives stable ids and setGeometryField, setGeometryWkbType and setGeometrySrid let the layer open without executing the query.",[44,528],{"x":46,"y":46,"width":47,"height":529,"fill":49},"276",[68,531,532],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Guessing costs a query and stable ids",[44,534],{"x":78,"y":535,"width":536,"height":537,"rx":143,"fill":99,"stroke":538,"style":146},"50","344","204","#b91c1c",[68,540,544],{"x":541,"y":542,"style":543,"fill":538,"textAnchor":74},"196","76","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","left to the provider",[68,546,550],{"x":547,"y":548,"style":549,"fill":66},"48","108","font-size:10.5px;font-family:sans-serif","query executed just to open the layer",[68,552,554],{"x":547,"y":553,"style":549,"fill":66},"136","geometry type read from first rows",[68,556,558],{"x":547,"y":557,"style":549,"fill":66},"164","feature id = row position",[68,560,562],{"x":547,"y":561,"style":549,"fill":66},"192","selection jumps after a refresh",[68,564,567],{"x":541,"y":565,"style":566,"fill":538,"textAnchor":74},"232","text-anchor:middle;font-size:10px;font-family:sans-serif","fine for a quick look",[44,569],{"x":570,"y":535,"width":536,"height":537,"rx":143,"fill":179,"stroke":180,"style":146},"392",[68,572,574],{"x":573,"y":542,"style":543,"fill":185,"textAnchor":74},"564","declared in the definition",[68,576,579],{"x":577,"y":548,"style":578,"fill":66},"416","font-size:10px;font-family:monospace","setUid(\"ward_id\")",[68,581,582],{"x":577,"y":553,"style":578,"fill":66},"setGeometryField(\"geometry\")",[68,584,585],{"x":577,"y":557,"style":578,"fill":66},"setGeometryWkbType(…)",[68,587,588],{"x":577,"y":561,"style":578,"fill":66},"setGeometrySrid(27700)",[68,590,591],{"x":573,"y":565,"style":566,"fill":185,"textAnchor":74},"what a script should do",[201,593,595],{"id":594},"reference-layers-that-are-not-in-the-project","Reference layers that are not in the project",[14,597,598,599,602],{},"In a standalone script there may be no project layers at all. ",[223,600,601],{},"addSource"," registers a table by name, provider and source string, so the definition carries everything it needs.",[239,604,606],{"className":241,"code":605,"language":243,"meta":244,"style":244},"definition = QgsVirtualLayerDefinition()\ndefinition.addSource(\"wards\", \"\u002Fdata\u002Fboundaries.gpkg|layername=wards\", \"ogr\")\ndefinition.addSource(\n    \"readings\",\n    \"file:\u002F\u002F\u002Fdata\u002Freadings.csv?type=csv&delimiter=,&detectTypes=yes&geomType=none\",\n    \"delimitedtext\",\n)\ndefinition.setQuery(\"\"\"\nSELECT r.station_id, date(r.read_at) AS day, max(r.pm25) AS peak\nFROM readings AS r\nWHERE r.pm25 > 35\nGROUP BY r.station_id, day\n\"\"\")\n\npeaks = QgsVectorLayer(definition.toString(), \"daily peaks\", \"virtual\")\nprint(peaks.isValid(), peaks.featureCount())\nfor f in peaks.getFeatures():\n    print(f[\"station_id\"], f[\"day\"], f[\"peak\"])\n",[223,607,608,616,636,641,649,656,663,667,674,679,684,689,694,701,705,723,731,745],{"__ignoreMap":244},[248,609,610,612,614],{"class":123,"line":250},[248,611,355],{"class":257},[248,613,280],{"class":253},[248,615,360],{"class":257},[248,617,618,621,624,626,629,631,634],{"class":123,"line":267},[248,619,620],{"class":257},"definition.addSource(",[248,622,623],{"class":283},"\"wards\"",[248,625,429],{"class":257},[248,627,628],{"class":283},"\"\u002Fdata\u002Fboundaries.gpkg|layername=wards\"",[248,630,429],{"class":257},[248,632,633],{"class":283},"\"ogr\"",[248,635,378],{"class":257},[248,637,638],{"class":123,"line":274},[248,639,640],{"class":257},"definition.addSource(\n",[248,642,643,646],{"class":123,"line":287},[248,644,645],{"class":283},"    \"readings\"",[248,647,648],{"class":257},",\n",[248,650,651,654],{"class":123,"line":293},[248,652,653],{"class":283},"    \"file:\u002F\u002F\u002Fdata\u002Freadings.csv?type=csv&delimiter=,&detectTypes=yes&geomType=none\"",[248,655,648],{"class":257},[248,657,658,661],{"class":123,"line":299},[248,659,660],{"class":283},"    \"delimitedtext\"",[248,662,648],{"class":257},[248,664,665],{"class":123,"line":305},[248,666,378],{"class":257},[248,668,669,672],{"class":123,"line":311},[248,670,671],{"class":257},"definition.setQuery(",[248,673,344],{"class":283},[248,675,676],{"class":123,"line":317},[248,677,678],{"class":283},"SELECT r.station_id, date(r.read_at) AS day, max(r.pm25) AS peak\n",[248,680,681],{"class":123,"line":323},[248,682,683],{"class":283},"FROM readings AS r\n",[248,685,686],{"class":123,"line":329},[248,687,688],{"class":283},"WHERE r.pm25 > 35\n",[248,690,691],{"class":123,"line":335},[248,692,693],{"class":283},"GROUP BY r.station_id, day\n",[248,695,696,699],{"class":123,"line":341},[248,697,698],{"class":283},"\"\"\"",[248,700,378],{"class":257},[248,702,703],{"class":123,"line":347},[248,704,271],{"emptyLinePlaceholder":270},[248,706,707,710,712,714,717,719,721],{"class":123,"line":352},[248,708,709],{"class":257},"peaks ",[248,711,280],{"class":253},[248,713,423],{"class":257},[248,715,716],{"class":283},"\"daily peaks\"",[248,718,429],{"class":257},[248,720,432],{"class":283},[248,722,378],{"class":257},[248,724,725,728],{"class":123,"line":363},[248,726,727],{"class":404},"print",[248,729,730],{"class":257},"(peaks.isValid(), peaks.featureCount())\n",[248,732,733,736,739,742],{"class":123,"line":369},[248,734,735],{"class":253},"for",[248,737,738],{"class":257}," f ",[248,740,741],{"class":253},"in",[248,743,744],{"class":257}," peaks.getFeatures():\n",[248,746,747,750,753,756,759,762,764,767],{"class":123,"line":381},[248,748,749],{"class":404},"    print",[248,751,752],{"class":257},"(f[",[248,754,755],{"class":283},"\"station_id\"",[248,757,758],{"class":257},"], f[",[248,760,761],{"class":283},"\"day\"",[248,763,758],{"class":257},[248,765,766],{"class":283},"\"peak\"",[248,768,769],{"class":257},"])\n",[14,771,772,774,775,779,780,783],{},[212,773,486],{}," A query with no geometry column produces a geometryless layer — a table you can iterate, join or export like any other. The CSV source string is the same one covered in ",[21,776,778],{"href":777},"\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fload-csv-as-point-layer-pyqgis\u002F","loading a CSV as a point layer",", with ",[223,781,782],{},"geomType=none"," because the readings table has no location of its own. Here no uid is set on purpose: an aggregate like this has no natural integer key, and the result is read once rather than selected or edited, so row-number ids are harmless.",[201,785,787],{"id":786},"keep-spatial-joins-fast","Keep spatial joins fast",[14,789,790,791,794,795,798],{},"A spatial predicate in a ",[223,792,793],{},"JOIN"," compares every row with every row unless it is told otherwise. For two layers of a few thousand features that is millions of geometry tests, which is the usual reason a virtual layer \"hangs\". The provider exposes each source's spatial index through a hidden ",[223,796,797],{},"_search_frame_"," column: constraining it to the other feature's bounding box turns the join into an indexed lookup.",[14,800,801],{},[29,802,805,808,811,814,817,820,824,829,835,837,839,842,844,847,850,853,856,858,861,865,869,872,875,877,880,883,886,889],{"viewBox":803,"role":32,"ariaLabel":804,"xmlns":34},"0 0 760 260","Brute force versus indexed spatial join: without the search frame every point is tested against every polygon; with it only points inside each polygon's bounding box reach the exact test",[36,806,807],{},"The _search_frame_ shortcut",[40,809,810],{},"Left: 5,000 points times 600 polygons means three million exact ST_Within tests. Right: the search frame constraint asks each point layer's spatial index for candidates inside a polygon's bounding box, leaving a few thousand exact tests. The query result is identical; only the work differs.",[44,812],{"x":46,"y":46,"width":47,"height":813,"fill":49},"260",[68,815,816],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Same answer, a thousandth of the geometry tests",[44,818],{"x":78,"y":547,"width":536,"height":819,"rx":143,"fill":144,"stroke":538,"style":146},"190",[68,821,823],{"x":541,"y":822,"style":543,"fill":538,"textAnchor":74},"74","plain ST_Within join",[44,825],{"x":826,"y":827,"width":103,"height":140,"fill":99,"stroke":100,"style":828},"120","92","stroke-width:1.5",[830,831],"circle",{"cx":832,"cy":833,"r":834,"fill":538},"60","110","3",[830,836],{"cx":126,"cy":107,"r":834,"fill":538},[830,838],{"cx":103,"cy":826,"r":834,"fill":538},[830,840],{"cx":841,"cy":103,"r":834,"fill":538},"200",[830,843],{"cx":48,"cy":176,"r":834,"fill":538},[830,845],{"cx":846,"cy":819,"r":834,"fill":538},"330",[830,848],{"cx":849,"cy":841,"r":834,"fill":538},"250",[830,851],{"cx":852,"cy":841,"r":834,"fill":538},"40",[68,854,855],{"x":541,"y":116,"style":198,"fill":66,"textAnchor":74},"every point tested: 5,000 × 600",[44,857],{"x":570,"y":547,"width":536,"height":819,"rx":143,"fill":144,"stroke":180,"style":146},[68,859,860],{"x":573,"y":822,"style":543,"fill":185,"textAnchor":74},"with _search_frame_",[44,862],{"x":863,"y":827,"width":103,"height":140,"fill":179,"stroke":180,"style":864},"488","stroke-width:1.5;stroke-dasharray:5 3",[830,866],{"cx":867,"cy":833,"r":834,"fill":868},"428","#9aa39d",[830,870],{"cx":871,"cy":107,"r":834,"fill":868},"450",[830,873],{"cx":874,"cy":826,"r":834,"fill":180},"518",[830,876],{"cx":175,"cy":103,"r":834,"fill":180},[830,878],{"cx":879,"cy":176,"r":834,"fill":868},"668",[830,881],{"cx":882,"cy":819,"r":834,"fill":868},"698",[830,884],{"cx":885,"cy":841,"r":834,"fill":868},"618",[830,887],{"cx":888,"cy":841,"r":834,"fill":868},"408",[68,890,891],{"x":573,"y":116,"style":198,"fill":66,"textAnchor":74},"only points inside the box are tested",[239,893,895],{"className":241,"code":894,"language":243,"meta":244,"style":244},"sql = \"\"\"\nSELECT w.fid AS ward_id, w.ward_code, count(*) AS incidents, w.geometry\nFROM wards AS w\nJOIN incidents AS i\n  ON i._search_frame_ = w.geometry\n AND ST_Within(i.geometry, w.geometry)\nGROUP BY w.fid\n\"\"\"\ndefinition = QgsVirtualLayerDefinition()\ndefinition.setQuery(sql)\ndefinition.setUid(\"ward_id\")\ndefinition.setGeometryField(\"geometry\")\ndefinition.setGeometrySrid(27700)\nfast = QgsVectorLayer(definition.toString(), \"incidents per ward\", \"virtual\")\n",[223,896,897,905,910,914,919,924,929,933,937,945,949,957,965,973],{"__ignoreMap":244},[248,898,899,901,903],{"class":123,"line":250},[248,900,277],{"class":257},[248,902,280],{"class":253},[248,904,284],{"class":283},[248,906,907],{"class":123,"line":267},[248,908,909],{"class":283},"SELECT w.fid AS ward_id, w.ward_code, count(*) AS incidents, w.geometry\n",[248,911,912],{"class":123,"line":274},[248,913,320],{"class":283},[248,915,916],{"class":123,"line":287},[248,917,918],{"class":283},"JOIN incidents AS i\n",[248,920,921],{"class":123,"line":293},[248,922,923],{"class":283},"  ON i._search_frame_ = w.geometry\n",[248,925,926],{"class":123,"line":299},[248,927,928],{"class":283}," AND ST_Within(i.geometry, w.geometry)\n",[248,930,931],{"class":123,"line":305},[248,932,338],{"class":283},[248,934,935],{"class":123,"line":311},[248,936,344],{"class":283},[248,938,939,941,943],{"class":123,"line":317},[248,940,355],{"class":257},[248,942,280],{"class":253},[248,944,360],{"class":257},[248,946,947],{"class":123,"line":323},[248,948,366],{"class":257},[248,950,951,953,955],{"class":123,"line":329},[248,952,372],{"class":257},[248,954,375],{"class":283},[248,956,378],{"class":257},[248,958,959,961,963],{"class":123,"line":335},[248,960,384],{"class":257},[248,962,387],{"class":283},[248,964,378],{"class":257},[248,966,967,969,971],{"class":123,"line":341},[248,968,401],{"class":257},[248,970,405],{"class":404},[248,972,378],{"class":257},[248,974,975,978,980,982,985,987,989],{"class":123,"line":347},[248,976,977],{"class":257},"fast ",[248,979,280],{"class":253},[248,981,423],{"class":257},[248,983,984],{"class":283},"\"incidents per ward\"",[248,986,429],{"class":257},[248,988,432],{"class":283},[248,990,378],{"class":257},[14,992,993,995,996,999,1000,1003,1004,1007,1008,1011],{},[212,994,486],{}," ",[223,997,998],{},"i._search_frame_ = w.geometry"," reads oddly but means \"use the index on ",[223,1001,1002],{},"incidents"," to fetch rows whose bounding box intersects the ward's bounding box\". The exact ",[223,1005,1006],{},"ST_Within"," test then runs only on those candidates. Both layers must be in the same CRS — the virtual engine does no reprojection, so a mismatch produces an empty result rather than an error. If the source layer has no spatial index (a delimited text file loaded without ",[223,1009,1010],{},"spatialIndex=yes",", for instance), the frame falls back to a scan and you are back to brute force.",[201,1013,1015],{"id":1014},"read-back-and-change-an-existing-definition","Read back and change an existing definition",[14,1017,1018],{},"A virtual layer saved in a project stores its whole definition in the source string. To inspect or alter it — change a threshold, add a column, point it at a renamed table — parse the source back into a definition rather than editing the URL by hand.",[239,1020,1022],{"className":241,"code":1021,"language":243,"meta":244,"style":244},"from qgis.PyQt.QtCore import QUrl\nfrom qgis.core import QgsDataProvider\n\nlayer = QgsProject.instance().mapLayersByName(\"incidents per ward\")[0]\ndefinition = QgsVirtualLayerDefinition.fromUrl(QUrl.fromEncoded(layer.source().encode()))\nprint(definition.query())\nprint([s.name() for s in definition.sourceLayers()])\n\ndefinition.setQuery(definition.query().replace(\n    \"JOIN incidents AS i\", \"JOIN incidents_2026 AS i\"))\n\nlayer.setDataSource(definition.toString(), layer.name(), \"virtual\",\n                    QgsDataProvider.ProviderOptions())\nprint(layer.isValid(), layer.featureCount())\n",[223,1023,1024,1036,1047,1051,1071,1080,1087,1104,1108,1113,1126,1130,1139,1144],{"__ignoreMap":244},[248,1025,1026,1028,1031,1033],{"class":123,"line":250},[248,1027,254],{"class":253},[248,1029,1030],{"class":257}," qgis.PyQt.QtCore ",[248,1032,261],{"class":253},[248,1034,1035],{"class":257}," QUrl\n",[248,1037,1038,1040,1042,1044],{"class":123,"line":267},[248,1039,254],{"class":253},[248,1041,258],{"class":257},[248,1043,261],{"class":253},[248,1045,1046],{"class":257}," QgsDataProvider\n",[248,1048,1049],{"class":123,"line":274},[248,1050,271],{"emptyLinePlaceholder":270},[248,1052,1053,1056,1058,1061,1063,1066,1068],{"class":123,"line":287},[248,1054,1055],{"class":257},"layer ",[248,1057,280],{"class":253},[248,1059,1060],{"class":257}," QgsProject.instance().mapLayersByName(",[248,1062,984],{"class":283},[248,1064,1065],{"class":257},")[",[248,1067,46],{"class":404},[248,1069,1070],{"class":257},"]\n",[248,1072,1073,1075,1077],{"class":123,"line":293},[248,1074,355],{"class":257},[248,1076,280],{"class":253},[248,1078,1079],{"class":257}," QgsVirtualLayerDefinition.fromUrl(QUrl.fromEncoded(layer.source().encode()))\n",[248,1081,1082,1084],{"class":123,"line":299},[248,1083,727],{"class":404},[248,1085,1086],{"class":257},"(definition.query())\n",[248,1088,1089,1091,1094,1096,1099,1101],{"class":123,"line":305},[248,1090,727],{"class":404},[248,1092,1093],{"class":257},"([s.name() ",[248,1095,735],{"class":253},[248,1097,1098],{"class":257}," s ",[248,1100,741],{"class":253},[248,1102,1103],{"class":257}," definition.sourceLayers()])\n",[248,1105,1106],{"class":123,"line":311},[248,1107,271],{"emptyLinePlaceholder":270},[248,1109,1110],{"class":123,"line":317},[248,1111,1112],{"class":257},"definition.setQuery(definition.query().replace(\n",[248,1114,1115,1118,1120,1123],{"class":123,"line":323},[248,1116,1117],{"class":283},"    \"JOIN incidents AS i\"",[248,1119,429],{"class":257},[248,1121,1122],{"class":283},"\"JOIN incidents_2026 AS i\"",[248,1124,1125],{"class":257},"))\n",[248,1127,1128],{"class":123,"line":329},[248,1129,271],{"emptyLinePlaceholder":270},[248,1131,1132,1135,1137],{"class":123,"line":335},[248,1133,1134],{"class":257},"layer.setDataSource(definition.toString(), layer.name(), ",[248,1136,432],{"class":283},[248,1138,648],{"class":257},[248,1140,1141],{"class":123,"line":341},[248,1142,1143],{"class":257},"                    QgsDataProvider.ProviderOptions())\n",[248,1145,1146,1148],{"class":123,"line":347},[248,1147,727],{"class":404},[248,1149,1150],{"class":257},"(layer.isValid(), layer.featureCount())\n",[14,1152,1153,995,1155,1158,1159,1162,1163,1166,1167,1169,1170,1173,1174,1178],{},[212,1154,486],{},[223,1156,1157],{},"fromUrl"," reverses ",[223,1160,1161],{},"toString",", so the query, uid, geometry declarations and any explicit sources come back as objects you can read and set. ",[223,1164,1165],{},"sourceLayers()"," lists only the sources added with ",[223,1168,601],{},"; tables referenced by project layer name are resolved at open time and do not appear there, which is worth knowing before you assume a definition is self-contained. Applying the change with ",[223,1171,1172],{},"setDataSource"," rather than creating a new layer keeps the style, the layer id and any layout references — the same technique as ",[21,1175,1177],{"href":1176},"\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fdecode-and-build-data-source-uris-pyqgis\u002F","repointing any other layer",".",[14,1180,1181],{},"String replacement on SQL is fine for a one-off change you can eyeball. For anything driven by user input — a plugin dialog that lets someone pick a year, say — build the whole query from validated parts instead. The virtual provider has no bound parameters, so a value pasted straight into the SQL is an injection vector in exactly the way it would be against a database, and a stray quote in a place name is enough to make the layer invalid.",[201,1183,1185],{"id":1184},"execute-sql-a-database-or-processing","Execute SQL, a database, or Processing?",[14,1187,1188],{},"Virtual layers are lazy: every render, every identify and every attribute table refresh re-runs the query. That is what makes them live, and it is why a heavy query on a layer you pan around feels sluggish. When you only need the answer once, materialise it.",[239,1190,1192],{"className":241,"code":1191,"language":243,"meta":244,"style":244},"import processing\n\nresult = processing.run(\"qgis:executesql\", {\n    \"INPUT_DATASOURCES\": [wards, incidents],\n    \"INPUT_QUERY\": \"\"\"\n        SELECT w.fid AS ward_id, w.ward_code, count(*) AS incidents, w.geometry\n        FROM input1 AS w JOIN input2 AS i\n          ON ST_Within(i.geometry, w.geometry)\n        GROUP BY w.fid\n    \"\"\",\n    \"INPUT_UID_FIELD\": \"ward_id\",\n    \"INPUT_GEOMETRY_FIELD\": \"geometry\",\n    \"OUTPUT\": \"\u002Fdata\u002Fwork\u002Fincidents_per_ward.gpkg\",\n})\n",[223,1193,1194,1201,1205,1221,1229,1239,1244,1249,1254,1259,1266,1277,1288,1300],{"__ignoreMap":244},[248,1195,1196,1198],{"class":123,"line":250},[248,1197,261],{"class":253},[248,1199,1200],{"class":257}," processing\n",[248,1202,1203],{"class":123,"line":267},[248,1204,271],{"emptyLinePlaceholder":270},[248,1206,1207,1210,1212,1215,1218],{"class":123,"line":274},[248,1208,1209],{"class":257},"result ",[248,1211,280],{"class":253},[248,1213,1214],{"class":257}," processing.run(",[248,1216,1217],{"class":283},"\"qgis:executesql\"",[248,1219,1220],{"class":257},", {\n",[248,1222,1223,1226],{"class":123,"line":287},[248,1224,1225],{"class":283},"    \"INPUT_DATASOURCES\"",[248,1227,1228],{"class":257},": [wards, incidents],\n",[248,1230,1231,1234,1237],{"class":123,"line":293},[248,1232,1233],{"class":283},"    \"INPUT_QUERY\"",[248,1235,1236],{"class":257},": ",[248,1238,344],{"class":283},[248,1240,1241],{"class":123,"line":299},[248,1242,1243],{"class":283},"        SELECT w.fid AS ward_id, w.ward_code, count(*) AS incidents, w.geometry\n",[248,1245,1246],{"class":123,"line":305},[248,1247,1248],{"class":283},"        FROM input1 AS w JOIN input2 AS i\n",[248,1250,1251],{"class":123,"line":311},[248,1252,1253],{"class":283},"          ON ST_Within(i.geometry, w.geometry)\n",[248,1255,1256],{"class":123,"line":317},[248,1257,1258],{"class":283},"        GROUP BY w.fid\n",[248,1260,1261,1264],{"class":123,"line":323},[248,1262,1263],{"class":283},"    \"\"\"",[248,1265,648],{"class":257},[248,1267,1268,1271,1273,1275],{"class":123,"line":329},[248,1269,1270],{"class":283},"    \"INPUT_UID_FIELD\"",[248,1272,1236],{"class":257},[248,1274,375],{"class":283},[248,1276,648],{"class":257},[248,1278,1279,1282,1284,1286],{"class":123,"line":335},[248,1280,1281],{"class":283},"    \"INPUT_GEOMETRY_FIELD\"",[248,1283,1236],{"class":257},[248,1285,387],{"class":283},[248,1287,648],{"class":257},[248,1289,1290,1293,1295,1298],{"class":123,"line":341},[248,1291,1292],{"class":283},"    \"OUTPUT\"",[248,1294,1236],{"class":257},[248,1296,1297],{"class":283},"\"\u002Fdata\u002Fwork\u002Fincidents_per_ward.gpkg\"",[248,1299,648],{"class":257},[248,1301,1302],{"class":123,"line":347},[248,1303,1304],{"class":257},"})\n",[14,1306,1307,995,1309,1313,1314,429,1317,1320,1321,1325,1326,1330],{},[212,1308,486],{},[1310,1311,1312],"em",{},"Execute SQL"," uses the same engine but names the inputs ",[223,1315,1316],{},"input1",[223,1318,1319],{},"input2"," in the order given, and writes a real output instead of a view. For a spatial join that Processing already offers as a dedicated algorithm — ",[21,1322,1324],{"href":1323},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis\u002F","joining attributes by location",", or counting points in polygons — the dedicated algorithm is usually faster still. And when every source already lives in PostGIS, write the query there, as ",[21,1327,1329],{"href":1328},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fexecute-sql-on-postgis-pyqgis\u002F","executing SQL on PostGIS"," shows; pulling rows into SQLite to join them is strictly slower than letting the database do it.",[201,1332,1334],{"id":1333},"qgis-version-compatibility","QGIS version compatibility",[14,1336,1337,1338,1340,1341,1343,1344,1347,1348,1351,1352,1355,1356,1359],{},"The virtual layer provider and ",[223,1339,236],{}," have been stable since QGIS 3.0, and ",[223,1342,797],{}," since 2.14. ",[223,1345,1346],{},"qgis:executesql"," kept its ",[223,1349,1350],{},"qgis:"," prefix through 3.44; check ",[223,1353,1354],{},"QgsApplication.processingRegistry().algorithmById(\"qgis:executesql\")"," on the QGIS 4 series in case it has moved to the ",[223,1357,1358],{},"native:"," provider in your build. The SpatiaLite function set follows the SpatiaLite version bundled with your installer.",[201,1361,1363],{"id":1362},"troubleshooting","Troubleshooting",[206,1365,1366,1375,1381,1390,1399],{},[209,1367,1368,1371,1372,1374],{},[212,1369,1370],{},"\"no such table\"."," The table name does not match a layer name, or the layer is not in the project and no ",[223,1373,601],{}," was given.",[209,1376,1377,1380],{},[212,1378,1379],{},"Empty result from a spatial join."," The layers are in different CRSs. Reproject one first.",[209,1382,1383,1386,1387,1389],{},[212,1384,1385],{},"The layer takes minutes to open."," No geometry declarations, so the query runs at open time; add them, and add ",[223,1388,797],{}," to spatial joins.",[209,1391,1392,1395,1396,1178],{},[212,1393,1394],{},"Geometry column not recognised."," Aliased as something other than the declared geometry field, or an expression result that SQLite returns as a blob without SRID — wrap it in ",[223,1397,1398],{},"SetSRID(…, 27700)",[209,1400,1401,1404],{},[212,1402,1403],{},"Feature selection jumps around."," No uid declared.",[201,1406,1408],{"id":1407},"conclusion","Conclusion",[14,1410,1411,1412,1414,1415,1417],{},"Use a virtual layer when you want a live SQL view across layers from different providers, declare the uid and geometry so nothing is guessed, and add ",[223,1413,797],{}," to every spatial join. When you need the answer rather than the view, run ",[1310,1416,1312],{}," or push the query into the database that already holds the data.",[201,1419,1421],{"id":1420},"frequently-asked-questions","Frequently Asked Questions",[14,1423,1424,1427],{},[212,1425,1426],{},"Can I edit a virtual layer?","\nNo. It is read-only; edit the sources and the view reflects the change.",[14,1429,1430,1433],{},[212,1431,1432],{},"Does the virtual layer save with the project?","\nYes — the definition, including the query and any sources, is stored in the project file.",[14,1435,1436,1439],{},[212,1437,1438],{},"Can I use QGIS expression functions in the SQL?","\nOnly SQLite and SpatiaLite functions are available inside the query. Apply QGIS expressions afterwards with a field calculation or a filter on the resulting layer.",[14,1441,1442,1445,1446,1178],{},[212,1443,1444],{},"How do I reference a layer whose name contains spaces or dots?","\nQuote it with double quotes in the SQL, or register it under a simple alias with ",[223,1447,601],{},[201,1449,1451],{"id":1450},"related","Related",[206,1453,1454,1459,1464,1469,1474],{},[209,1455,1456,1458],{},[21,1457,24],{"href":23}," — the guide this recipe belongs to",[209,1460,1461],{},[21,1462,1463],{"href":777},"Load a CSV as a Point Layer in PyQGIS",[209,1465,1466],{},[21,1467,1468],{"href":1328},"Execute SQL on PostGIS from PyQGIS",[209,1470,1471],{},[21,1472,1473],{"href":1323},"Spatial Join Points to Polygons in PyQGIS",[209,1475,1476],{},[21,1477,1479],{"href":1478},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002F","Build and Use a Spatial Index in PyQGIS",[1481,1482,1483],"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":244,"searchDepth":267,"depth":267,"links":1485},[1486,1487,1488,1489,1490,1491,1492,1493,1494,1495,1496],{"id":203,"depth":267,"text":204},{"id":229,"depth":267,"text":230},{"id":594,"depth":267,"text":595},{"id":786,"depth":267,"text":787},{"id":1014,"depth":267,"text":1015},{"id":1184,"depth":267,"text":1185},{"id":1333,"depth":267,"text":1334},{"id":1362,"depth":267,"text":1363},{"id":1407,"depth":267,"text":1408},{"id":1420,"depth":267,"text":1421},{"id":1450,"depth":267,"text":1451},"Run SQL joins, aggregates and spatial queries across any mix of loaded layers with the virtual layer provider — building the definition in Python, declaring geometry and a unique id, and knowing when Execute SQL or a database is the better tool.","md",{"slug":1500,"type":1501,"breadcrumb":1502,"datePublished":1503,"dateModified":1503},"query-layers-with-virtual-layer-sql-pyqgis","article","Query Layers with Virtual Layer SQL","2026-09-17","\u002Fspatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fquery-layers-with-virtual-layer-sql-pyqgis",{"title":5,"description":1497},"spatial-data-processing-automation\u002Flayer-data-sources-and-formats\u002Fquery-layers-with-virtual-layer-sql-pyqgis\u002Findex","C4_LxxyGpATVvRLLEzaSISNkvsoPYcKtXq8XC1ESALg",1789632908454]