[{"data":1,"prerenderedAt":1498},["ShallowReactive",2],{"doc:\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis":3},{"id":4,"title":5,"body":6,"description":1487,"extension":1488,"meta":1489,"navigation":247,"path":1494,"seo":1495,"stem":1496,"__hash__":1497},"docs\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis\u002Findex.md","Understand QGIS Signals and Slots in PyQGIS",{"type":7,"value":8,"toc":1474},"minimark",[9,13,17,26,184,189,208,212,215,342,348,356,365,382,393,397,400,406,497,509,515,617,626,742,746,749,820,840,844,847,1076,1095,1103,1107,1110,1175,1219,1234,1238,1244,1327,1331,1375,1379,1386,1390,1400,1410,1420,1438,1442,1470],[10,11,5],"h1",{"id":12},"understand-qgis-signals-and-slots-in-pyqgis",[14,15,16],"p",{},"Almost everything reactive in QGIS is a Qt signal. A layer announces that its features changed, the project announces that it was read, the canvas announces that the extent moved — and any amount of code can listen without those objects knowing anything about the listeners. Understanding the mechanism turns a large part of the QGIS API from a list of classes into a system you can hook into.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002F","QGIS API Architecture in PyQGIS",". It covers connecting and disconnecting, the two ways Python code accidentally breaks the connection, suppressing signals during bulk edits, and the signals that come up most often on projects, layers and the canvas.",[14,27,28],{},[29,30,35,39,43,50,67,76,86,91,96,100,108,111,115,124,130,134,139,143,147,152,156,160,167,172,176,180],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 316","img","One signal emitted by a layer reaching several independent connected slots, none of which the layer knows about","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"One emitter, many independent listeners",[40,41,42],"desc",{},"A vector layer emits a features changed signal. Three unrelated pieces of code are connected to it: a plugin panel refreshing a count, a validation routine checking a rule, and a status bar message. The layer holds no reference to any of them and does not know how many there are.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","316","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"sigArrow","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","The emitter knows nothing about who is listening",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":82,"fill":83,"stroke":84,"style":85},"24","122","196","86","10","#eff3ff","#2563eb","stroke-width:2.5",[68,87,90],{"x":79,"y":88,"style":89,"fill":84,"textAnchor":74},"152","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","QgsVectorLayer",[68,92,95],{"x":79,"y":93,"style":94,"fill":66,"textAnchor":74},"176","text-anchor:middle;font-size:10.5px;font-family:monospace","featureAdded",[68,97,99],{"x":79,"y":80,"style":98,"fill":66,"textAnchor":74},"text-anchor:middle;font-size:10px;font-family:sans-serif","emits, then forgets",[101,102],"line",{"x1":103,"y1":104,"x2":105,"y2":106,"stroke":66,"style":107},"226","146","300","88","stroke-width:2;marker-end:url(#sigArrow)",[101,109],{"x1":103,"y1":110,"x2":105,"y2":110,"stroke":66,"style":107},"165",[101,112],{"x1":103,"y1":113,"x2":105,"y2":114,"stroke":66,"style":107},"184","242",[44,116],{"x":117,"y":118,"width":119,"height":120,"rx":58,"fill":121,"stroke":122,"style":123},"308","56","240","64","#edf8e9","#15803d","stroke-width:2",[68,125,129],{"x":126,"y":127,"style":128,"fill":122,"textAnchor":74},"428","82","text-anchor:middle;font-size:10.5px;font-weight:bold;font-family:sans-serif","plugin panel",[68,131,133],{"x":126,"y":132,"style":98,"fill":66,"textAnchor":74},"102","refresh the feature count",[44,135],{"x":117,"y":136,"width":119,"height":120,"rx":58,"fill":137,"stroke":138,"style":123},"134","#fdf2e2","#b45309",[68,140,142],{"x":126,"y":141,"style":128,"fill":138,"textAnchor":74},"160","validation",[68,144,146],{"x":126,"y":145,"style":98,"fill":66,"textAnchor":74},"180","check the new geometry",[44,148],{"x":117,"y":149,"width":119,"height":120,"rx":58,"fill":150,"stroke":151,"style":123},"212","#eef7f4","#0f766e",[68,153,155],{"x":126,"y":154,"style":128,"fill":151,"textAnchor":74},"238","status bar",[68,157,159],{"x":126,"y":158,"style":98,"fill":66,"textAnchor":74},"258","show a message",[44,161],{"x":162,"y":163,"width":141,"height":164,"rx":82,"fill":165,"stroke":166,"style":85},"576","112","106","#fffdf7","#b91c1c",[68,168,171],{"x":169,"y":170,"style":128,"fill":166,"textAnchor":74},"656","140","the consequence",[68,173,175],{"x":169,"y":174,"style":98,"fill":66,"textAnchor":74},"164","a slot that raises",[68,177,179],{"x":169,"y":178,"style":98,"fill":66,"textAnchor":74},"182","can stop the others",[68,181,183],{"x":169,"y":182,"style":98,"fill":66,"textAnchor":74},"204","from running",[185,186,188],"h2",{"id":187},"prerequisites","Prerequisites",[190,191,192,200],"ul",{},[193,194,195,199],"li",{},[196,197,198],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer, and a Python console or plugin to experiment in.",[193,201,202,203,207],{},"No Qt background required, but knowing that ",[204,205,206],"code",{},"connect"," takes any callable helps.",[185,209,211],{"id":210},"connect-and-disconnect","Connect and disconnect",[14,213,214],{},"A signal is an attribute on an object; connecting attaches a callable to it.",[216,217,222],"pre",{"className":218,"code":219,"language":220,"meta":221,"style":221},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProject\n\ndef on_layers_added(layers):\n    for layer in layers:\n        print(f\"added {layer.name()} ({layer.crs().authid()})\")\n\nproject = QgsProject.instance()\nproject.layersAdded.connect(on_layers_added)\n","python","",[204,223,224,242,249,262,277,319,324,336],{"__ignoreMap":221},[225,226,228,232,236,239],"span",{"class":101,"line":227},1,[225,229,231],{"class":230},"snl16","from",[225,233,235],{"class":234},"s95oV"," qgis.core ",[225,237,238],{"class":230},"import",[225,240,241],{"class":234}," QgsProject\n",[225,243,245],{"class":101,"line":244},2,[225,246,248],{"emptyLinePlaceholder":247},true,"\n",[225,250,252,255,259],{"class":101,"line":251},3,[225,253,254],{"class":230},"def",[225,256,258],{"class":257},"svObZ"," on_layers_added",[225,260,261],{"class":234},"(layers):\n",[225,263,265,268,271,274],{"class":101,"line":264},4,[225,266,267],{"class":230},"    for",[225,269,270],{"class":234}," layer ",[225,272,273],{"class":230},"in",[225,275,276],{"class":234}," layers:\n",[225,278,280,284,287,290,294,297,300,303,306,308,311,313,316],{"class":101,"line":279},5,[225,281,283],{"class":282},"sDLfK","        print",[225,285,286],{"class":234},"(",[225,288,289],{"class":230},"f",[225,291,293],{"class":292},"sU2Wk","\"added ",[225,295,296],{"class":282},"{",[225,298,299],{"class":234},"layer.name()",[225,301,302],{"class":282},"}",[225,304,305],{"class":292}," (",[225,307,296],{"class":282},[225,309,310],{"class":234},"layer.crs().authid()",[225,312,302],{"class":282},[225,314,315],{"class":292},")\"",[225,317,318],{"class":234},")\n",[225,320,322],{"class":101,"line":321},6,[225,323,248],{"emptyLinePlaceholder":247},[225,325,327,330,333],{"class":101,"line":326},7,[225,328,329],{"class":234},"project ",[225,331,332],{"class":230},"=",[225,334,335],{"class":234}," QgsProject.instance()\n",[225,337,339],{"class":101,"line":338},8,[225,340,341],{"class":234},"project.layersAdded.connect(on_layers_added)\n",[14,343,344,347],{},[196,345,346],{},"Breakdown:"," The callable's signature must accept whatever the signal emits — here a list of layers. Connecting a function that takes no arguments to a signal that emits one raises at emission time, not at connection time, which is why a typo here surfaces much later than you would like. A plain module-level function is the easiest thing to connect and the easiest to disconnect.",[14,349,350,351,355],{},"Disconnecting requires the ",[352,353,354],"em",{},"same"," callable object:",[216,357,359],{"className":218,"code":358,"language":220,"meta":221,"style":221},"project.layersAdded.disconnect(on_layers_added)\n",[204,360,361],{"__ignoreMap":221},[225,362,363],{"class":101,"line":227},[225,364,358],{"class":234},[14,366,367,369,370,373,374,377,378,381],{},[196,368,346],{}," Passing a different-but-equivalent function does nothing and raises ",[204,371,372],{},"TypeError"," in PyQt. That is the reason lambdas are hard to disconnect: ",[204,375,376],{},"lambda l: print(l)"," creates a new object every time it is written, so there is no way to name the one that was connected. Keeping a reference — ",[204,379,380],{},"self._handler = lambda l: ..."," — makes it possible, but a bound method is usually cleaner.",[14,383,384,385,388,389,392],{},"Calling ",[204,386,387],{},"disconnect()"," with no arguments removes every connection to that signal, including ones other code made. In a plugin's ",[204,390,391],{},"unload()"," that is tempting and wrong; disconnect only what you connected.",[185,394,396],{"id":395},"the-two-ways-python-breaks-the-connection","The two ways Python breaks the connection",[14,398,399],{},"Both are about object lifetime, and both produce the same symptom: the slot silently stops running.",[14,401,402,405],{},[196,403,404],{},"A garbage-collected receiver."," PyQt keeps only a weak reference to a bound method's object. If nothing else holds the object, it is collected and the connection quietly dies.",[216,407,409],{"className":218,"code":408,"language":220,"meta":221,"style":221},"class Watcher:\n    def __init__(self, layer):\n        layer.featureAdded.connect(self.on_added)\n\n    def on_added(self, fid):\n        print(\"added\", fid)\n\nWatcher(layer)          # collected immediately — the slot never fires\nwatcher = Watcher(layer)  # kept alive — works\n",[204,410,411,422,433,444,448,458,470,474,483],{"__ignoreMap":221},[225,412,413,416,419],{"class":101,"line":227},[225,414,415],{"class":230},"class",[225,417,418],{"class":257}," Watcher",[225,420,421],{"class":234},":\n",[225,423,424,427,430],{"class":101,"line":244},[225,425,426],{"class":230},"    def",[225,428,429],{"class":282}," __init__",[225,431,432],{"class":234},"(self, layer):\n",[225,434,435,438,441],{"class":101,"line":251},[225,436,437],{"class":234},"        layer.featureAdded.connect(",[225,439,440],{"class":282},"self",[225,442,443],{"class":234},".on_added)\n",[225,445,446],{"class":101,"line":264},[225,447,248],{"emptyLinePlaceholder":247},[225,449,450,452,455],{"class":101,"line":279},[225,451,426],{"class":230},[225,453,454],{"class":257}," on_added",[225,456,457],{"class":234},"(self, fid):\n",[225,459,460,462,464,467],{"class":101,"line":321},[225,461,283],{"class":282},[225,463,286],{"class":234},[225,465,466],{"class":292},"\"added\"",[225,468,469],{"class":234},", fid)\n",[225,471,472],{"class":101,"line":326},[225,473,248],{"emptyLinePlaceholder":247},[225,475,476,479],{"class":101,"line":338},[225,477,478],{"class":234},"Watcher(layer)          ",[225,480,482],{"class":481},"sjoCn","# collected immediately — the slot never fires\n",[225,484,486,489,491,494],{"class":101,"line":485},9,[225,487,488],{"class":234},"watcher ",[225,490,332],{"class":230},[225,492,493],{"class":234}," Watcher(layer)  ",[225,495,496],{"class":481},"# kept alive — works\n",[14,498,499,501,502,505,506,508],{},[196,500,346],{}," The first line creates a ",[204,503,504],{},"Watcher",", connects it, and drops the only reference to it. The connection survives just long enough to be useless. In a plugin, storing handlers on ",[204,507,440],{}," is what keeps them alive; in a script, a module-level variable does the same.",[14,510,511,514],{},[196,512,513],{},"A lambda capturing a loop variable."," Python closes over the variable, not its value, so every lambda in the loop sees the final value.",[216,516,518],{"className":218,"code":517,"language":220,"meta":221,"style":221},"for layer in layers:\n    layer.willBeDeleted.connect(lambda: print(f\"gone: {layer.name()}\"))   # all report the last layer\n\nfor layer in layers:\n    layer.willBeDeleted.connect(lambda name=layer.name(): print(f\"gone: {name}\"))   # correct\n",[204,519,520,531,567,571,581],{"__ignoreMap":221},[225,521,522,525,527,529],{"class":101,"line":227},[225,523,524],{"class":230},"for",[225,526,270],{"class":234},[225,528,273],{"class":230},[225,530,276],{"class":234},[225,532,533,536,539,542,545,547,549,552,554,556,558,561,564],{"class":101,"line":244},[225,534,535],{"class":234},"    layer.willBeDeleted.connect(",[225,537,538],{"class":230},"lambda",[225,540,541],{"class":234},": ",[225,543,544],{"class":282},"print",[225,546,286],{"class":234},[225,548,289],{"class":230},[225,550,551],{"class":292},"\"gone: ",[225,553,296],{"class":282},[225,555,299],{"class":234},[225,557,302],{"class":282},[225,559,560],{"class":292},"\"",[225,562,563],{"class":234},"))   ",[225,565,566],{"class":481},"# all report the last layer\n",[225,568,569],{"class":101,"line":251},[225,570,248],{"emptyLinePlaceholder":247},[225,572,573,575,577,579],{"class":101,"line":264},[225,574,524],{"class":230},[225,576,270],{"class":234},[225,578,273],{"class":230},[225,580,276],{"class":234},[225,582,583,585,587,590,592,595,597,599,601,603,605,608,610,612,614],{"class":101,"line":279},[225,584,535],{"class":234},[225,586,538],{"class":230},[225,588,589],{"class":234}," name",[225,591,332],{"class":230},[225,593,594],{"class":234},"layer.name(): ",[225,596,544],{"class":282},[225,598,286],{"class":234},[225,600,289],{"class":230},[225,602,551],{"class":292},[225,604,296],{"class":282},[225,606,607],{"class":234},"name",[225,609,302],{"class":282},[225,611,560],{"class":292},[225,613,563],{"class":234},[225,615,616],{"class":481},"# correct\n",[14,618,619,621,622,625],{},[196,620,346],{}," The default-argument trick binds the value at definition time and is the standard fix. ",[204,623,624],{},"functools.partial"," does the same more explicitly. This bug is particularly nasty with signals because it produces plausible-looking output — every handler runs, they just all describe the wrong object.",[14,627,628],{},[29,629,632,635,638,641,648,651,656,662,670,675,678,681,684,689,694,700,704,707,711,717,721,724,728,733,736,739],{"viewBox":630,"role":32,"ariaLabel":631,"xmlns":34},"0 0 760 288","A connection surviving because the receiving object is referenced, compared with one dying because the object was garbage collected immediately after connecting",[36,633,634],{},"Why a connected slot stops firing",[40,636,637],{},"PyQt holds only a weak reference to the object owning a bound method slot. When something else keeps that object alive, the connection works. When the object is created inline and immediately dropped, it is garbage collected and the connection silently stops working with no error.",[44,639],{"x":46,"y":46,"width":47,"height":640,"fill":49},"288",[51,642,643],{},[54,644,646],{"id":645,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"lifeArrow",[63,647],{"d":65,"fill":66},[68,649,650],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"A weak reference means nothing is keeping it alive for you",[44,652],{"x":653,"y":654,"width":655,"height":182,"rx":82,"fill":121,"stroke":122,"style":85},"26","52","330",[68,657,661],{"x":658,"y":659,"style":660,"fill":122,"textAnchor":74},"191","80","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","reference kept",[44,663],{"x":654,"y":664,"width":665,"height":666,"rx":667,"fill":668,"stroke":122,"style":669},"98","120","44","6","#e8efe6","stroke-width:1.8",[68,671,674],{"x":163,"y":672,"style":673,"fill":66,"textAnchor":74},"125","text-anchor:middle;font-size:10px;font-family:monospace","self.watcher",[44,676],{"x":677,"y":664,"width":665,"height":666,"rx":667,"fill":668,"stroke":122,"style":669},"210",[68,679,504],{"x":680,"y":672,"style":98,"fill":66,"textAnchor":74},"270",[101,682],{"x1":93,"y1":665,"x2":182,"y2":665,"stroke":66,"style":683},"stroke-width:2;marker-end:url(#lifeArrow)",[44,685],{"x":686,"y":687,"width":665,"height":666,"rx":667,"fill":688,"stroke":84,"style":669},"130","172","#dbeafe",[68,690,693],{"x":691,"y":692,"style":98,"fill":66,"textAnchor":74},"190","199","signal",[101,695],{"x1":696,"y1":697,"x2":680,"y2":698,"stroke":122,"style":699},"250","188","150","stroke-width:2;stroke-dasharray:4 3;marker-end:url(#lifeArrow)",[68,701,703],{"x":658,"y":119,"style":702,"fill":122,"textAnchor":74},"text-anchor:middle;font-size:10.5px;font-family:sans-serif","the slot fires",[44,705],{"x":706,"y":654,"width":655,"height":182,"rx":82,"fill":137,"stroke":166,"style":85},"404",[68,708,710],{"x":709,"y":659,"style":660,"fill":166,"textAnchor":74},"569","no reference",[44,712],{"x":713,"y":664,"width":665,"height":666,"rx":667,"fill":714,"stroke":715,"style":716},"430","#efeadd","#59645f","stroke-width:1.4;stroke-dasharray:4 3",[68,718,720],{"x":719,"y":672,"style":98,"fill":715,"textAnchor":74},"490","nothing",[44,722],{"x":723,"y":664,"width":665,"height":666,"rx":667,"fill":714,"stroke":715,"style":716},"588",[68,725,504],{"x":726,"y":727,"style":98,"fill":715,"textAnchor":74},"648","119",[68,729,732],{"x":726,"y":730,"style":731,"fill":166,"textAnchor":74},"135","text-anchor:middle;font-size:9px;font-family:sans-serif","collected",[44,734],{"x":735,"y":687,"width":665,"height":666,"rx":667,"fill":688,"stroke":84,"style":669},"508",[68,737,693],{"x":738,"y":692,"style":98,"fill":66,"textAnchor":74},"568",[68,740,741],{"x":709,"y":119,"style":702,"fill":166,"textAnchor":74},"nothing happens, no error",[185,743,745],{"id":744},"suppressing-signals-during-bulk-work","Suppressing signals during bulk work",[14,747,748],{},"Every change emits, and a loop of ten thousand changes emits ten thousand times — each one repainting a canvas or refreshing a panel.",[216,750,752],{"className":218,"code":751,"language":220,"meta":221,"style":221},"layer.blockSignals(True)\ntry:\n    layer.startEditing()\n    for feature in layer.getFeatures():\n        layer.changeAttributeValue(feature.id(), index, new_value)\n    layer.commitChanges()\nfinally:\n    layer.blockSignals(False)\nlayer.triggerRepaint()\n",[204,753,754,764,771,776,788,793,798,805,815],{"__ignoreMap":221},[225,755,756,759,762],{"class":101,"line":227},[225,757,758],{"class":234},"layer.blockSignals(",[225,760,761],{"class":282},"True",[225,763,318],{"class":234},[225,765,766,769],{"class":101,"line":244},[225,767,768],{"class":230},"try",[225,770,421],{"class":234},[225,772,773],{"class":101,"line":251},[225,774,775],{"class":234},"    layer.startEditing()\n",[225,777,778,780,783,785],{"class":101,"line":264},[225,779,267],{"class":230},[225,781,782],{"class":234}," feature ",[225,784,273],{"class":230},[225,786,787],{"class":234}," layer.getFeatures():\n",[225,789,790],{"class":101,"line":279},[225,791,792],{"class":234},"        layer.changeAttributeValue(feature.id(), index, new_value)\n",[225,794,795],{"class":101,"line":321},[225,796,797],{"class":234},"    layer.commitChanges()\n",[225,799,800,803],{"class":101,"line":326},[225,801,802],{"class":230},"finally",[225,804,421],{"class":234},[225,806,807,810,813],{"class":101,"line":338},[225,808,809],{"class":234},"    layer.blockSignals(",[225,811,812],{"class":282},"False",[225,814,318],{"class":234},[225,816,817],{"class":101,"line":485},[225,818,819],{"class":234},"layer.triggerRepaint()\n",[14,821,822,824,825,828,829,831,832,835,836,839],{},[196,823,346],{}," ",[204,826,827],{},"blockSignals(True)"," suppresses every signal from that object until it is unblocked — including ones you do want, which is why the ",[204,830,802],{}," matters and why the explicit repaint afterwards is necessary. Note that blocked signals are ",[352,833,834],{},"dropped",", not queued: listeners never learn about the individual changes, so a panel that maintains an incremental count must be told to refresh wholesale afterwards. For canvas work specifically, ",[204,837,838],{},"canvas.setRenderFlag(False)"," is the better tool because it suppresses drawing without suppressing notification.",[185,841,843],{"id":842},"debouncing-an-expensive-handler","Debouncing an expensive handler",[14,845,846],{},"Signals that fire continuously — canvas extent, selection during a rubber-band drag, attribute changes during a bulk edit — need the handler to run once when the activity settles rather than on every emission.",[216,848,850],{"className":218,"code":849,"language":220,"meta":221,"style":221},"from qgis.PyQt.QtCore import QTimer\n\n\nclass DebouncedExtentWatcher:\n    def __init__(self, canvas, callback, delay_ms=250):\n        self.canvas = canvas\n        self.callback = callback\n        self.timer = QTimer()\n        self.timer.setSingleShot(True)\n        self.timer.setInterval(delay_ms)\n        self.timer.timeout.connect(self._fire)\n        canvas.extentsChanged.connect(self._schedule)\n\n    def _schedule(self):\n        self.timer.start()          # restarts if already running\n\n    def _fire(self):\n        self.callback(self.canvas.extent(), self.canvas.scale())\n\n    def detach(self):\n        self.canvas.extentsChanged.disconnect(self._schedule)\n        self.timer.timeout.disconnect(self._fire)\n",[204,851,852,864,868,872,881,897,910,922,934,945,953,966,977,982,993,1004,1009,1019,1037,1042,1052,1064],{"__ignoreMap":221},[225,853,854,856,859,861],{"class":101,"line":227},[225,855,231],{"class":230},[225,857,858],{"class":234}," qgis.PyQt.QtCore ",[225,860,238],{"class":230},[225,862,863],{"class":234}," QTimer\n",[225,865,866],{"class":101,"line":244},[225,867,248],{"emptyLinePlaceholder":247},[225,869,870],{"class":101,"line":251},[225,871,248],{"emptyLinePlaceholder":247},[225,873,874,876,879],{"class":101,"line":264},[225,875,415],{"class":230},[225,877,878],{"class":257}," DebouncedExtentWatcher",[225,880,421],{"class":234},[225,882,883,885,887,890,892,894],{"class":101,"line":279},[225,884,426],{"class":230},[225,886,429],{"class":282},[225,888,889],{"class":234},"(self, canvas, callback, delay_ms",[225,891,332],{"class":230},[225,893,696],{"class":282},[225,895,896],{"class":234},"):\n",[225,898,899,902,905,907],{"class":101,"line":321},[225,900,901],{"class":282},"        self",[225,903,904],{"class":234},".canvas ",[225,906,332],{"class":230},[225,908,909],{"class":234}," canvas\n",[225,911,912,914,917,919],{"class":101,"line":326},[225,913,901],{"class":282},[225,915,916],{"class":234},".callback ",[225,918,332],{"class":230},[225,920,921],{"class":234}," callback\n",[225,923,924,926,929,931],{"class":101,"line":338},[225,925,901],{"class":282},[225,927,928],{"class":234},".timer ",[225,930,332],{"class":230},[225,932,933],{"class":234}," QTimer()\n",[225,935,936,938,941,943],{"class":101,"line":485},[225,937,901],{"class":282},[225,939,940],{"class":234},".timer.setSingleShot(",[225,942,761],{"class":282},[225,944,318],{"class":234},[225,946,948,950],{"class":101,"line":947},10,[225,949,901],{"class":282},[225,951,952],{"class":234},".timer.setInterval(delay_ms)\n",[225,954,956,958,961,963],{"class":101,"line":955},11,[225,957,901],{"class":282},[225,959,960],{"class":234},".timer.timeout.connect(",[225,962,440],{"class":282},[225,964,965],{"class":234},"._fire)\n",[225,967,969,972,974],{"class":101,"line":968},12,[225,970,971],{"class":234},"        canvas.extentsChanged.connect(",[225,973,440],{"class":282},[225,975,976],{"class":234},"._schedule)\n",[225,978,980],{"class":101,"line":979},13,[225,981,248],{"emptyLinePlaceholder":247},[225,983,985,987,990],{"class":101,"line":984},14,[225,986,426],{"class":230},[225,988,989],{"class":257}," _schedule",[225,991,992],{"class":234},"(self):\n",[225,994,996,998,1001],{"class":101,"line":995},15,[225,997,901],{"class":282},[225,999,1000],{"class":234},".timer.start()          ",[225,1002,1003],{"class":481},"# restarts if already running\n",[225,1005,1007],{"class":101,"line":1006},16,[225,1008,248],{"emptyLinePlaceholder":247},[225,1010,1012,1014,1017],{"class":101,"line":1011},17,[225,1013,426],{"class":230},[225,1015,1016],{"class":257}," _fire",[225,1018,992],{"class":234},[225,1020,1022,1024,1027,1029,1032,1034],{"class":101,"line":1021},18,[225,1023,901],{"class":282},[225,1025,1026],{"class":234},".callback(",[225,1028,440],{"class":282},[225,1030,1031],{"class":234},".canvas.extent(), ",[225,1033,440],{"class":282},[225,1035,1036],{"class":234},".canvas.scale())\n",[225,1038,1040],{"class":101,"line":1039},19,[225,1041,248],{"emptyLinePlaceholder":247},[225,1043,1045,1047,1050],{"class":101,"line":1044},20,[225,1046,426],{"class":230},[225,1048,1049],{"class":257}," detach",[225,1051,992],{"class":234},[225,1053,1055,1057,1060,1062],{"class":101,"line":1054},21,[225,1056,901],{"class":282},[225,1058,1059],{"class":234},".canvas.extentsChanged.disconnect(",[225,1061,440],{"class":282},[225,1063,976],{"class":234},[225,1065,1067,1069,1072,1074],{"class":101,"line":1066},22,[225,1068,901],{"class":282},[225,1070,1071],{"class":234},".timer.timeout.disconnect(",[225,1073,440],{"class":282},[225,1075,965],{"class":234},[14,1077,1078,824,1080,1083,1084,1087,1088,1091,1092,1094],{},[196,1079,346],{},[204,1081,1082],{},"start()"," on a running single-shot timer restarts it, so a burst of twenty extent changes during a pan results in exactly one callback a quarter of a second after the user stops. The timer must be stored on the instance — a local ",[204,1085,1086],{},"QTimer"," is collected as soon as the constructor returns, and then nothing ever fires, which is the same lifetime trap in a different costume. ",[204,1089,1090],{},"detach()"," exists because a plugin's ",[204,1093,391],{}," needs a way to leave the canvas as it found it.",[14,1096,1097,1098,1102],{},"Two hundred and fifty milliseconds is a good default: short enough to feel immediate, long enough that a continuous pan does not trigger it once per frame. For work measured in seconds rather than milliseconds, push it into a ",[21,1099,1101],{"href":1100},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Frun-background-task-with-qgstask-pyqgis\u002F","background task"," instead, because even a debounced handler blocks the interface while it runs.",[185,1104,1106],{"id":1105},"the-signals-worth-knowing","The signals worth knowing",[14,1108,1109],{},"A handful come up constantly, and knowing them saves inventing polling loops.",[14,1111,1112,1113,541,1116,1119,1120,1123,1124,1127,1128,1123,1131,1134,1135,1138,1139,1119,1141,1123,1144,1147,1148,1123,1151,1154,1155,1158,1159,1162,1163,1166,1167,1170,1171,1174],{},"On ",[204,1114,1115],{},"QgsProject",[204,1117,1118],{},"layersAdded",", ",[204,1121,1122],{},"layersRemoved"," and ",[204,1125,1126],{},"layerWasAdded"," for the layer set; ",[204,1129,1130],{},"readProject",[204,1132,1133],{},"writeProject"," for hooking into load and save; ",[204,1136,1137],{},"cleared"," before a new project replaces the current one. On a vector layer: ",[204,1140,95],{},[204,1142,1143],{},"featureDeleted",[204,1145,1146],{},"attributeValueChanged"," during editing; ",[204,1149,1150],{},"editingStarted",[204,1152,1153],{},"editingStopped","; ",[204,1156,1157],{},"selectionChanged"," for anything that follows the user's selection; ",[204,1160,1161],{},"dataChanged"," when the underlying source changed. On the canvas: ",[204,1164,1165],{},"extentsChanged"," after a pan or zoom, ",[204,1168,1169],{},"scaleChanged"," when the denominator moves, and ",[204,1172,1173],{},"mapCanvasRefreshed"," after a redraw completes.",[216,1176,1178],{"className":218,"code":1177,"language":220,"meta":221,"style":221},"iface.mapCanvas().extentsChanged.connect(\n    lambda: print(f\"1:{iface.mapCanvas().scale():.0f}\")\n)\n",[204,1179,1180,1185,1215],{"__ignoreMap":221},[225,1181,1182],{"class":101,"line":227},[225,1183,1184],{"class":234},"iface.mapCanvas().extentsChanged.connect(\n",[225,1186,1187,1190,1192,1194,1196,1198,1201,1203,1206,1209,1211,1213],{"class":101,"line":244},[225,1188,1189],{"class":230},"    lambda",[225,1191,541],{"class":234},[225,1193,544],{"class":282},[225,1195,286],{"class":234},[225,1197,289],{"class":230},[225,1199,1200],{"class":292},"\"1:",[225,1202,296],{"class":282},[225,1204,1205],{"class":234},"iface.mapCanvas().scale()",[225,1207,1208],{"class":230},":.0f",[225,1210,302],{"class":282},[225,1212,560],{"class":292},[225,1214,318],{"class":234},[225,1216,1217],{"class":101,"line":251},[225,1218,318],{"class":234},[14,1220,1221,824,1223,1225,1226,1228,1229,1233],{},[196,1222,346],{},[204,1224,1165],{}," fires on every pan step, so a handler doing real work needs debouncing — a ",[204,1227,1086],{}," with a short single-shot interval, restarted on each emission, is the standard pattern. Connecting expensive work directly to this signal is the most common cause of a plugin that makes panning feel sticky. See ",[21,1230,1232],{"href":1231},"\u002Fqgis-plugin-development\u002Fcustom-map-tools-and-canvas-interaction\u002Fconnect-layer-signals-pyqgis\u002F","connecting layer signals"," for the layer-side equivalents.",[185,1235,1237],{"id":1236},"qgis-version-compatibility","QGIS version compatibility",[14,1239,1240,1241,1243],{},"The examples target ",[196,1242,198],{}," (Python 3.12).",[1245,1246,1247,1263],"table",{},[1248,1249,1250],"thead",{},[1251,1252,1253,1257,1260],"tr",{},[1254,1255,1256],"th",{},"QGIS version",[1254,1258,1259],{},"Python",[1254,1261,1262],{},"Notes",[1264,1265,1266,1278,1292,1302,1313],"tbody",{},[1251,1267,1268,1272,1275],{},[1269,1270,1271],"td",{},"3.16 LTR",[1269,1273,1274],{},"3.7",[1269,1276,1277],{},"Signal names used here all present; PyQt5 semantics.",[1251,1279,1280,1283,1286],{},[1269,1281,1282],{},"3.22 LTR",[1269,1284,1285],{},"3.9",[1269,1287,1288,1291],{},[204,1289,1290],{},"QgsProject.layersAdded"," and related signals unchanged.",[1251,1293,1294,1297,1299],{},[1269,1295,1296],{},"3.28 LTR",[1269,1298,1285],{},[1269,1300,1301],{},"Some layer signals gained overloads; connect to the documented signature.",[1251,1303,1304,1307,1310],{},[1269,1305,1306],{},"3.34 LTR",[1269,1308,1309],{},"3.12",[1269,1311,1312],{},"Baseline for this page.",[1251,1314,1315,1318,1320],{},[1269,1316,1317],{},"3.40+",[1269,1319,1309],{},[1269,1321,1322,1323,1326],{},"Qt6 builds appear; ",[204,1324,1325],{},"QtCore.pyqtSignal"," semantics are unchanged for consumers.",[185,1328,1330],{"id":1329},"troubleshooting","Troubleshooting",[190,1332,1333,1342,1348,1357,1363,1369],{},[193,1334,1335,1338,1339,1341],{},[196,1336,1337],{},"The slot never fires."," The receiving object was garbage collected. Store a reference on ",[204,1340,440],{}," or at module level.",[193,1343,1344,1347],{},[196,1345,1346],{},"Every handler reports the last item."," A lambda captured a loop variable. Bind it with a default argument.",[193,1349,1350,1356],{},[196,1351,1352,1355],{},[204,1353,1354],{},"disconnect"," raises TypeError."," The callable passed is not the one that was connected. Keep the reference.",[193,1358,1359,1362],{},[196,1360,1361],{},"The slot fires many times per action."," It was connected more than once. Disconnect before connecting, or guard with a flag.",[193,1364,1365,1368],{},[196,1366,1367],{},"A bulk edit is unbearably slow."," Signals are firing per change. Block them, or suspend canvas rendering.",[193,1370,1371,1374],{},[196,1372,1373],{},"An exception in one slot broke the others."," Slots run in connection order and an unhandled exception can stop the chain. Wrap slot bodies in try\u002Fexcept.",[185,1376,1378],{"id":1377},"conclusion","Conclusion",[14,1380,1381,1382,1385],{},"Keep a reference to anything whose bound method you connect, bind loop variables explicitly, disconnect exactly what you connected, and reach for ",[204,1383,1384],{},"blockSignals"," or the canvas render flag before writing a loop that changes thousands of things. Most of the QGIS API becomes reactive once these four habits are automatic.",[185,1387,1389],{"id":1388},"frequently-asked-questions","Frequently Asked Questions",[14,1391,1392,1395,1396,1399],{},[196,1393,1394],{},"What is the difference between a signal and a slot in Python?","\nIn PyQt, any callable can act as a slot — a function, a bound method, a lambda. The ",[204,1397,1398],{},"@pyqtSlot"," decorator exists for performance and for cross-thread connections, and is optional for ordinary use.",[14,1401,1402,1405,1406,1409],{},[196,1403,1404],{},"Can I connect one signal to another?","\nYes, ",[204,1407,1408],{},"signal_a.connect(signal_b)"," chains them, which is occasionally useful for re-emitting a lower-level event through a plugin's own public signal.",[14,1411,1412,1415,1416,1419],{},[196,1413,1414],{},"Do signals work across threads?","\nYes, and Qt queues them automatically when the receiver lives in a different thread — which is what makes ",[21,1417,1418],{"href":1100},"QgsTask"," safe to report progress from a worker.",[14,1421,1422,1425,1428,1429,1432,1433,1437],{},[196,1423,1424],{},"How do I find what signals a class has?",[204,1426,1427],{},"dir()"," on the object shows them, or ",[204,1430,1431],{},"help()"," in the console. The API documentation lists them per class, and ",[21,1434,1436],{"href":1435},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Fexplore-pyqgis-api-with-dir-and-help\u002F","exploring the API with dir and help"," covers the technique.",[185,1439,1441],{"id":1440},"related","Related",[190,1443,1444,1449,1454,1459,1464],{},[193,1445,1446,1448],{},[21,1447,24],{"href":23}," — the guide this recipe belongs to",[193,1450,1451],{},[21,1452,1453],{"href":1231},"Connect Layer Signals in PyQGIS",[193,1455,1456],{},[21,1457,1458],{"href":1435},"Explore the PyQGIS API with dir and help",[193,1460,1461],{},[21,1462,1463],{"href":1100},"Run a Background Task with QgsTask in PyQGIS",[193,1465,1466],{},[21,1467,1469],{"href":1468},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Fqgis-core-gui-analysis-modules-explained\u002F","QGIS Core, GUI and Analysis Modules Explained",[1471,1472,1473],"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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}",{"title":221,"searchDepth":244,"depth":244,"links":1475},[1476,1477,1478,1479,1480,1481,1482,1483,1484,1485,1486],{"id":187,"depth":244,"text":188},{"id":210,"depth":244,"text":211},{"id":395,"depth":244,"text":396},{"id":744,"depth":244,"text":745},{"id":842,"depth":244,"text":843},{"id":1105,"depth":244,"text":1106},{"id":1236,"depth":244,"text":1237},{"id":1329,"depth":244,"text":1330},{"id":1377,"depth":244,"text":1378},{"id":1388,"depth":244,"text":1389},{"id":1440,"depth":244,"text":1441},"How Qt signals drive the QGIS API — connecting and disconnecting safely, lambda capture traps, keeping references alive, blockSignals, and the signals worth knowing on project, layer and canvas.","md",{"slug":1490,"type":1491,"breadcrumb":1492,"datePublished":1493,"dateModified":1493},"understand-qgis-signals-and-slots-pyqgis","article","Signals & Slots","2026-08-27","\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis",{"title":5,"description":1487},"pyqgis-fundamentals-environment-setup\u002Fqgis-api-architecture\u002Funderstand-qgis-signals-and-slots-pyqgis\u002Findex","s4ZGfdLFgT6qpVynOOoEOG4Qy95MbAmUkWGdHkzJsas",1787823360564]