[{"data":1,"prerenderedAt":1776},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fextract-elevation-profile-along-line-pyqgis":3},{"id":4,"title":5,"body":6,"description":1765,"extension":1766,"meta":1767,"navigation":319,"path":1772,"seo":1773,"stem":1774,"__hash__":1775},"docs\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fextract-elevation-profile-along-line-pyqgis\u002Findex.md","Extract an Elevation Profile Along a Line in PyQGIS",{"type":7,"value":8,"toc":1752},"minimark",[9,13,17,26,235,240,266,270,278,416,430,434,437,669,697,712,716,723,928,940,946,950,953,1324,1337,1405,1409,1412,1506,1523,1527,1533,1615,1619,1665,1669,1675,1679,1691,1702,1708,1714,1718,1748],[10,11,5],"h1",{"id":12},"extract-an-elevation-profile-along-a-line-in-pyqgis",[14,15,16],"p",{},"An elevation profile is a plot of height against distance along a route, and it is the question every pipeline, cycle path, sightline and cross-section starts with. QGIS has an interactive profile tool; what it does not have is a one-call algorithm that hands you the numbers. Doing it from Python takes about fifteen lines and gives you something the tool cannot: chainage, gradient, and a table you can put in a report.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002F","Terrain & Interpolation Analysis in PyQGIS",". It covers densifying the line to a sensible sampling interval, reading raster values efficiently, computing distance and gradient along the route, and turning the result into a layer or a CSV.",[14,27,28],{},[29,30,35,39,43,50,67,76,84,90,96,138,143,150,157,163,166,170,205,211,215,221,226,231],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 328","img","A route line crossing a DEM with sample points placed at a regular interval along it, and the resulting profile of elevation against distance below","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"From a route on the map to a profile",[40,41,42],"desc",{},"The route is densified so that vertices sit at a regular interval along its length. Each vertex is used to read one value from the elevation raster. Plotting those values against cumulative distance produces the profile, in which a steep section on the map appears as a steep segment on the chart.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","328","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"epArrow","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","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The sampling interval is the resolution of the answer",[44,77],{"x":71,"y":78,"width":79,"height":80,"rx":58,"fill":81,"stroke":82,"style":83},"46","300","150","#e7e2d4","#59645f","stroke-width:2",[68,85,89],{"x":86,"y":87,"style":88,"fill":82,"textAnchor":74},"176","68","text-anchor:middle;font-size:11px;font-family:sans-serif","DEM extent",[63,91],{"d":92,"fill":93,"stroke":94,"style":95},"M52 172 Q108 108 160 138 T256 100 T304 128","none","#b45309","stroke-width:3",[97,98,100,106,110,114,118,122,126,130,134],"g",{"fill":99},"#2563eb",[101,102],"circle",{"cx":103,"cy":104,"r":105},"52","172","3.4",[101,107],{"cx":108,"cy":109,"r":105},"84","136",[101,111],{"cx":112,"cy":113,"r":105},"116","114",[101,115],{"cx":116,"cy":117,"r":105},"148","132",[101,119],{"cx":120,"cy":121,"r":105},"180","140",[101,123],{"cx":124,"cy":125,"r":105},"212","122",[101,127],{"cx":128,"cy":129,"r":105},"244","102",[101,131],{"cx":132,"cy":133,"r":105},"276","108",[101,135],{"cx":136,"cy":137,"r":105},"304","128",[68,139,142],{"x":86,"y":140,"style":141,"fill":66,"textAnchor":74},"188","text-anchor:middle;font-size:10px;font-family:sans-serif","vertices every 25 m along the route",[144,145],"line",{"x1":146,"y1":147,"x2":148,"y2":147,"stroke":66,"style":149},"336","120","368","stroke-width:2;marker-end:url(#epArrow)",[44,151],{"x":152,"y":78,"width":153,"height":80,"rx":58,"fill":154,"stroke":155,"style":156},"382","352","#fffdf7","#0f766e","stroke-width:2.5",[144,158],{"x1":159,"y1":160,"x2":161,"y2":160,"stroke":66,"style":162},"420","170","712","stroke-width:1.8",[144,164],{"x1":159,"y1":165,"x2":159,"y2":160,"stroke":66,"style":162},"66",[63,167],{"d":168,"fill":93,"stroke":155,"style":169},"M420 152 L456 122 L492 104 L528 124 L564 132 L600 110 L636 84 L672 92 L708 118","stroke-width:2.6",[97,171,172,176,179,183,187,190,194,197,201],{"fill":155},[101,173],{"cx":159,"cy":174,"r":175},"152","3",[101,177],{"cx":178,"cy":125,"r":175},"456",[101,180],{"cx":181,"cy":182,"r":175},"492","104",[101,184],{"cx":185,"cy":186,"r":175},"528","124",[101,188],{"cx":189,"cy":117,"r":175},"564",[101,191],{"cx":192,"cy":193,"r":175},"600","110",[101,195],{"cx":196,"cy":108,"r":175},"636",[101,198],{"cx":199,"cy":200,"r":175},"672","92",[101,202],{"cx":203,"cy":204,"r":175},"708","118",[68,206,210],{"x":207,"y":208,"style":209,"fill":82},"404","70","text-anchor:end;font-size:9.5px;font-family:sans-serif","m",[68,212,214],{"x":213,"y":140,"style":141,"fill":66,"textAnchor":74},"566","chainage along the route",[44,216],{"x":147,"y":217,"width":218,"height":219,"rx":58,"fill":220,"stroke":94,"style":83},"222","520","82","#fdf2e2",[68,222,225],{"x":70,"y":223,"style":224,"fill":94,"textAnchor":74},"248","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","sample no finer than the cell size",[68,227,230],{"x":70,"y":228,"style":229,"fill":66,"textAnchor":74},"272","text-anchor:middle;font-size:10.5px;font-family:sans-serif","a 5 m interval on a 25 m DEM reads the same cell five times",[68,232,234],{"x":70,"y":233,"style":229,"fill":66,"textAnchor":74},"292","and draws a staircase that looks like terrain",[236,237,239],"h2",{"id":238},"prerequisites","Prerequisites",[241,242,243,251,254],"ul",{},[244,245,246,250],"li",{},[247,248,249],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[244,252,253],{},"A DEM in a projected CRS, with nodata declared.",[244,255,256,257,260,261,265],{},"A line layer in the ",[247,258,259],{},"same"," CRS as the DEM. If they differ, transform the geometry first — see ",[21,262,264],{"href":263},"\u002Fspatial-data-processing-automation\u002Fcoordinate-reference-systems\u002Ftransform-point-coordinates-pyqgis\u002F","transforming point coordinates",".",[236,267,269],{"id":268},"densify-the-line","Densify the line",[14,271,272,273,277],{},"A route drawn with four vertices produces four samples. Densifying puts a vertex every ",[274,275,276],"em",{},"n"," metres so the profile follows the ground.",[279,280,285],"pre",{"className":281,"code":282,"language":283,"meta":284,"style":284},"language-python shiki shiki-themes github-dark","import processing\nfrom qgis.core import QgsProject\n\nroute = QgsProject.instance().mapLayersByName(\"proposed_route\")[0]\n\ndensified = processing.run(\"native:densifygeometriesgivenaninterval\", {\n    \"INPUT\": route,\n    \"INTERVAL\": 25.0,\n    \"OUTPUT\": \"TEMPORARY_OUTPUT\",\n})[\"OUTPUT\"]\n","python","",[286,287,288,300,314,321,346,351,368,377,392,405],"code",{"__ignoreMap":284},[289,290,292,296],"span",{"class":144,"line":291},1,[289,293,295],{"class":294},"snl16","import",[289,297,299],{"class":298},"s95oV"," processing\n",[289,301,303,306,309,311],{"class":144,"line":302},2,[289,304,305],{"class":294},"from",[289,307,308],{"class":298}," qgis.core ",[289,310,295],{"class":294},[289,312,313],{"class":298}," QgsProject\n",[289,315,317],{"class":144,"line":316},3,[289,318,320],{"emptyLinePlaceholder":319},true,"\n",[289,322,324,327,330,333,337,340,343],{"class":144,"line":323},4,[289,325,326],{"class":298},"route ",[289,328,329],{"class":294},"=",[289,331,332],{"class":298}," QgsProject.instance().mapLayersByName(",[289,334,336],{"class":335},"sU2Wk","\"proposed_route\"",[289,338,339],{"class":298},")[",[289,341,46],{"class":342},"sDLfK",[289,344,345],{"class":298},"]\n",[289,347,349],{"class":144,"line":348},5,[289,350,320],{"emptyLinePlaceholder":319},[289,352,354,357,359,362,365],{"class":144,"line":353},6,[289,355,356],{"class":298},"densified ",[289,358,329],{"class":294},[289,360,361],{"class":298}," processing.run(",[289,363,364],{"class":335},"\"native:densifygeometriesgivenaninterval\"",[289,366,367],{"class":298},", {\n",[289,369,371,374],{"class":144,"line":370},7,[289,372,373],{"class":335},"    \"INPUT\"",[289,375,376],{"class":298},": route,\n",[289,378,380,383,386,389],{"class":144,"line":379},8,[289,381,382],{"class":335},"    \"INTERVAL\"",[289,384,385],{"class":298},": ",[289,387,388],{"class":342},"25.0",[289,390,391],{"class":298},",\n",[289,393,395,398,400,403],{"class":144,"line":394},9,[289,396,397],{"class":335},"    \"OUTPUT\"",[289,399,385],{"class":298},[289,401,402],{"class":335},"\"TEMPORARY_OUTPUT\"",[289,404,391],{"class":298},[289,406,408,411,414],{"class":144,"line":407},10,[289,409,410],{"class":298},"})[",[289,412,413],{"class":335},"\"OUTPUT\"",[289,415,345],{"class":298},[14,417,418,421,422,425,426,429],{},[247,419,420],{},"Breakdown:"," The interval is in layer units and should be at least the DEM's cell size — sampling a 25 m DEM every 5 m reads the same cell five times and draws a staircase that readers mistake for terrain. Densification adds vertices ",[274,423,424],{},"between"," existing ones without moving them, so the original geometry is preserved exactly and any sharp corner in the route survives. For a straight cross-section, ",[286,427,428],{},"native:pointsalonglines"," is the alternative and gives points directly rather than a denser line.",[236,431,433],{"id":432},"sample-the-raster","Sample the raster",[14,435,436],{},"Reading values one at a time through the data provider is the direct route and is fast enough for thousands of points.",[279,438,440],{"className":281,"code":439,"language":283,"meta":284,"style":284},"from qgis.core import QgsPointXY\n\ndem = QgsProject.instance().mapLayersByName(\"dem\")[0]\nprovider = dem.dataProvider()\nnodata = provider.sourceNoDataValue(1)\n\nfeature = next(densified.getFeatures())\nvertices = feature.geometry().asPolyline()\n\nprofile = []\ncumulative = 0.0\nprevious = None\n\nfor point in vertices:\n    value, ok = provider.sample(QgsPointXY(point), 1)\n    if previous is not None:\n        cumulative += previous.distance(point)\n    previous = point\n    if ok and value != nodata:\n        profile.append((cumulative, value))\n",[286,441,442,453,457,475,485,501,505,518,528,532,542,553,564,569,584,599,620,632,643,663],{"__ignoreMap":284},[289,443,444,446,448,450],{"class":144,"line":291},[289,445,305],{"class":294},[289,447,308],{"class":298},[289,449,295],{"class":294},[289,451,452],{"class":298}," QgsPointXY\n",[289,454,455],{"class":144,"line":302},[289,456,320],{"emptyLinePlaceholder":319},[289,458,459,462,464,466,469,471,473],{"class":144,"line":316},[289,460,461],{"class":298},"dem ",[289,463,329],{"class":294},[289,465,332],{"class":298},[289,467,468],{"class":335},"\"dem\"",[289,470,339],{"class":298},[289,472,46],{"class":342},[289,474,345],{"class":298},[289,476,477,480,482],{"class":144,"line":323},[289,478,479],{"class":298},"provider ",[289,481,329],{"class":294},[289,483,484],{"class":298}," dem.dataProvider()\n",[289,486,487,490,492,495,498],{"class":144,"line":348},[289,488,489],{"class":298},"nodata ",[289,491,329],{"class":294},[289,493,494],{"class":298}," provider.sourceNoDataValue(",[289,496,497],{"class":342},"1",[289,499,500],{"class":298},")\n",[289,502,503],{"class":144,"line":353},[289,504,320],{"emptyLinePlaceholder":319},[289,506,507,510,512,515],{"class":144,"line":370},[289,508,509],{"class":298},"feature ",[289,511,329],{"class":294},[289,513,514],{"class":342}," next",[289,516,517],{"class":298},"(densified.getFeatures())\n",[289,519,520,523,525],{"class":144,"line":379},[289,521,522],{"class":298},"vertices ",[289,524,329],{"class":294},[289,526,527],{"class":298}," feature.geometry().asPolyline()\n",[289,529,530],{"class":144,"line":394},[289,531,320],{"emptyLinePlaceholder":319},[289,533,534,537,539],{"class":144,"line":407},[289,535,536],{"class":298},"profile ",[289,538,329],{"class":294},[289,540,541],{"class":298}," []\n",[289,543,545,548,550],{"class":144,"line":544},11,[289,546,547],{"class":298},"cumulative ",[289,549,329],{"class":294},[289,551,552],{"class":342}," 0.0\n",[289,554,556,559,561],{"class":144,"line":555},12,[289,557,558],{"class":298},"previous ",[289,560,329],{"class":294},[289,562,563],{"class":342}," None\n",[289,565,567],{"class":144,"line":566},13,[289,568,320],{"emptyLinePlaceholder":319},[289,570,572,575,578,581],{"class":144,"line":571},14,[289,573,574],{"class":294},"for",[289,576,577],{"class":298}," point ",[289,579,580],{"class":294},"in",[289,582,583],{"class":298}," vertices:\n",[289,585,587,590,592,595,597],{"class":144,"line":586},15,[289,588,589],{"class":298},"    value, ok ",[289,591,329],{"class":294},[289,593,594],{"class":298}," provider.sample(QgsPointXY(point), ",[289,596,497],{"class":342},[289,598,500],{"class":298},[289,600,602,605,608,611,614,617],{"class":144,"line":601},16,[289,603,604],{"class":294},"    if",[289,606,607],{"class":298}," previous ",[289,609,610],{"class":294},"is",[289,612,613],{"class":294}," not",[289,615,616],{"class":342}," None",[289,618,619],{"class":298},":\n",[289,621,623,626,629],{"class":144,"line":622},17,[289,624,625],{"class":298},"        cumulative ",[289,627,628],{"class":294},"+=",[289,630,631],{"class":298}," previous.distance(point)\n",[289,633,635,638,640],{"class":144,"line":634},18,[289,636,637],{"class":298},"    previous ",[289,639,329],{"class":294},[289,641,642],{"class":298}," point\n",[289,644,646,648,651,654,657,660],{"class":144,"line":645},19,[289,647,604],{"class":294},[289,649,650],{"class":298}," ok ",[289,652,653],{"class":294},"and",[289,655,656],{"class":298}," value ",[289,658,659],{"class":294},"!=",[289,661,662],{"class":298}," nodata:\n",[289,664,666],{"class":144,"line":665},20,[289,667,668],{"class":298},"        profile.append((cumulative, value))\n",[14,670,671,673,674,677,678,681,682,685,686,689,690,692,693,696],{},[247,672,420],{}," ",[286,675,676],{},"provider.sample()"," returns a ",[286,679,680],{},"(value, ok)"," tuple, where ",[286,683,684],{},"ok"," is ",[286,687,688],{},"False"," outside the raster extent — checking it is what stops the profile silently gaining a zero where the route leaves the DEM. Comparing against the declared nodata catches gaps inside the extent, which ",[286,691,684],{}," does not. ",[286,694,695],{},"distance()"," between consecutive vertices accumulates chainage in layer units, so the first element of each tuple is metres along the route and the second is height, which is exactly the pair a profile needs.",[14,698,699,702,703,706,707,711],{},[286,700,701],{},"asPolyline()"," assumes a single-part line. A multipart route raises or returns an empty list, so a script handling arbitrary input should call ",[286,704,705],{},"asMultiPolyline()"," and iterate parts, resetting or continuing the chainage depending on whether the parts are contiguous. ",[21,708,710],{"href":709},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fconvert-multipart-to-singlepart-pyqgis\u002F","Converting multipart to singlepart"," beforehand sidesteps the question entirely.",[236,713,715],{"id":714},"sampling-faster-on-long-routes","Sampling faster on long routes",[14,717,718,719,722],{},"Calling ",[286,720,721],{},"sample()"," once per vertex is fine for a few thousand points and noticeably slow for a few hundred thousand — each call opens a small window into the raster and reads it. Where a route is long or there are many routes, reading in blocks is an order of magnitude quicker.",[279,724,726],{"className":281,"code":725,"language":283,"meta":284,"style":284},"from qgis.core import QgsRectangle\n\nextent = feature.geometry().boundingBox()\nextent.grow(dem.rasterUnitsPerPixelX() * 2)\n\nwidth = int(extent.width() \u002F dem.rasterUnitsPerPixelX())\nheight = int(extent.height() \u002F dem.rasterUnitsPerPixelY())\nblock = provider.block(1, extent, width, height)\n\ndef value_at(point):\n    col = int((point.x() - extent.xMinimum()) \u002F dem.rasterUnitsPerPixelX())\n    row = int((extent.yMaximum() - point.y()) \u002F dem.rasterUnitsPerPixelY())\n    if 0 \u003C= col \u003C width and 0 \u003C= row \u003C height:\n        return block.value(row, col)\n    return None\n",[286,727,728,739,743,753,766,770,789,806,821,825,837,859,880,913,921],{"__ignoreMap":284},[289,729,730,732,734,736],{"class":144,"line":291},[289,731,305],{"class":294},[289,733,308],{"class":298},[289,735,295],{"class":294},[289,737,738],{"class":298}," QgsRectangle\n",[289,740,741],{"class":144,"line":302},[289,742,320],{"emptyLinePlaceholder":319},[289,744,745,748,750],{"class":144,"line":316},[289,746,747],{"class":298},"extent ",[289,749,329],{"class":294},[289,751,752],{"class":298}," feature.geometry().boundingBox()\n",[289,754,755,758,761,764],{"class":144,"line":323},[289,756,757],{"class":298},"extent.grow(dem.rasterUnitsPerPixelX() ",[289,759,760],{"class":294},"*",[289,762,763],{"class":342}," 2",[289,765,500],{"class":298},[289,767,768],{"class":144,"line":348},[289,769,320],{"emptyLinePlaceholder":319},[289,771,772,775,777,780,783,786],{"class":144,"line":353},[289,773,774],{"class":298},"width ",[289,776,329],{"class":294},[289,778,779],{"class":342}," int",[289,781,782],{"class":298},"(extent.width() ",[289,784,785],{"class":294},"\u002F",[289,787,788],{"class":298}," dem.rasterUnitsPerPixelX())\n",[289,790,791,794,796,798,801,803],{"class":144,"line":370},[289,792,793],{"class":298},"height ",[289,795,329],{"class":294},[289,797,779],{"class":342},[289,799,800],{"class":298},"(extent.height() ",[289,802,785],{"class":294},[289,804,805],{"class":298}," dem.rasterUnitsPerPixelY())\n",[289,807,808,811,813,816,818],{"class":144,"line":379},[289,809,810],{"class":298},"block ",[289,812,329],{"class":294},[289,814,815],{"class":298}," provider.block(",[289,817,497],{"class":342},[289,819,820],{"class":298},", extent, width, height)\n",[289,822,823],{"class":144,"line":394},[289,824,320],{"emptyLinePlaceholder":319},[289,826,827,830,834],{"class":144,"line":407},[289,828,829],{"class":294},"def",[289,831,833],{"class":832},"svObZ"," value_at",[289,835,836],{"class":298},"(point):\n",[289,838,839,842,844,846,849,852,855,857],{"class":144,"line":544},[289,840,841],{"class":298},"    col ",[289,843,329],{"class":294},[289,845,779],{"class":342},[289,847,848],{"class":298},"((point.x() ",[289,850,851],{"class":294},"-",[289,853,854],{"class":298}," extent.xMinimum()) ",[289,856,785],{"class":294},[289,858,788],{"class":298},[289,860,861,864,866,868,871,873,876,878],{"class":144,"line":555},[289,862,863],{"class":298},"    row ",[289,865,329],{"class":294},[289,867,779],{"class":342},[289,869,870],{"class":298},"((extent.yMaximum() ",[289,872,851],{"class":294},[289,874,875],{"class":298}," point.y()) ",[289,877,785],{"class":294},[289,879,805],{"class":298},[289,881,882,884,887,890,893,896,899,901,903,905,908,910],{"class":144,"line":566},[289,883,604],{"class":294},[289,885,886],{"class":342}," 0",[289,888,889],{"class":294}," \u003C=",[289,891,892],{"class":298}," col ",[289,894,895],{"class":294},"\u003C",[289,897,898],{"class":298}," width ",[289,900,653],{"class":294},[289,902,886],{"class":342},[289,904,889],{"class":294},[289,906,907],{"class":298}," row ",[289,909,895],{"class":294},[289,911,912],{"class":298}," height:\n",[289,914,915,918],{"class":144,"line":571},[289,916,917],{"class":294},"        return",[289,919,920],{"class":298}," block.value(row, col)\n",[289,922,923,926],{"class":144,"line":586},[289,924,925],{"class":294},"    return",[289,927,563],{"class":342},[14,929,930,673,932,935,936,939],{},[247,931,420],{},[286,933,934],{},"provider.block()"," reads one rectangular region into memory in a single pass, after which each lookup is arithmetic rather than I\u002FO. The row calculation counts ",[274,937,938],{},"down"," from the top because raster rows are ordered north to south while y coordinates increase northwards — inverting that is the single most common bug in hand-written block indexing, and it produces a profile that is a mirror image of the real one. Growing the extent by a couple of cells avoids losing the endpoints to rounding.",[14,941,942,943,945],{},"This only pays off when the bounding box is a reasonable fraction of the route: a long diagonal line has a bounding box far larger than the corridor it occupies, and reading all of it into memory may cost more than the samples saved. For those, split the route into segments and read a block per segment, or stay with ",[286,944,721],{}," and accept the I\u002FO.",[236,947,949],{"id":948},"compute-gradient-and-the-numbers-people-ask-for","Compute gradient and the numbers people ask for",[14,951,952],{},"The profile is more useful once it carries derived values.",[279,954,956],{"className":281,"code":955,"language":283,"meta":284,"style":284},"rows = []\nfor index, (chainage, height) in enumerate(profile):\n    if index == 0:\n        gradient = 0.0\n    else:\n        run = chainage - profile[index - 1][0]\n        rise = height - profile[index - 1][1]\n        gradient = (rise \u002F run * 100.0) if run else 0.0\n    rows.append({\"chainage\": chainage, \"height\": height, \"gradient_pct\": gradient})\n\nclimb = sum(r[\"height\"] - p[\"height\"] for p, r in zip(rows, rows[1:]) if r[\"height\"] > p[\"height\"])\ndescent = sum(p[\"height\"] - r[\"height\"] for p, r in zip(rows, rows[1:]) if r[\"height\"] \u003C p[\"height\"])\nprint(f\"total climb {climb:.1f} m, total descent {descent:.1f} m, \"\n      f\"steepest {max(abs(r['gradient_pct']) for r in rows):.1f}%\")\n",[286,957,958,967,982,996,1005,1012,1039,1063,1095,1118,1122,1186,1240,1281],{"__ignoreMap":284},[289,959,960,963,965],{"class":144,"line":291},[289,961,962],{"class":298},"rows ",[289,964,329],{"class":294},[289,966,541],{"class":298},[289,968,969,971,974,976,979],{"class":144,"line":302},[289,970,574],{"class":294},[289,972,973],{"class":298}," index, (chainage, height) ",[289,975,580],{"class":294},[289,977,978],{"class":342}," enumerate",[289,980,981],{"class":298},"(profile):\n",[289,983,984,986,989,992,994],{"class":144,"line":316},[289,985,604],{"class":294},[289,987,988],{"class":298}," index ",[289,990,991],{"class":294},"==",[289,993,886],{"class":342},[289,995,619],{"class":298},[289,997,998,1001,1003],{"class":144,"line":323},[289,999,1000],{"class":298},"        gradient ",[289,1002,329],{"class":294},[289,1004,552],{"class":342},[289,1006,1007,1010],{"class":144,"line":348},[289,1008,1009],{"class":294},"    else",[289,1011,619],{"class":298},[289,1013,1014,1017,1019,1022,1024,1027,1029,1032,1035,1037],{"class":144,"line":353},[289,1015,1016],{"class":298},"        run ",[289,1018,329],{"class":294},[289,1020,1021],{"class":298}," chainage ",[289,1023,851],{"class":294},[289,1025,1026],{"class":298}," profile[index ",[289,1028,851],{"class":294},[289,1030,1031],{"class":342}," 1",[289,1033,1034],{"class":298},"][",[289,1036,46],{"class":342},[289,1038,345],{"class":298},[289,1040,1041,1044,1046,1049,1051,1053,1055,1057,1059,1061],{"class":144,"line":370},[289,1042,1043],{"class":298},"        rise ",[289,1045,329],{"class":294},[289,1047,1048],{"class":298}," height ",[289,1050,851],{"class":294},[289,1052,1026],{"class":298},[289,1054,851],{"class":294},[289,1056,1031],{"class":342},[289,1058,1034],{"class":298},[289,1060,497],{"class":342},[289,1062,345],{"class":298},[289,1064,1065,1067,1069,1072,1074,1077,1079,1082,1085,1088,1090,1093],{"class":144,"line":379},[289,1066,1000],{"class":298},[289,1068,329],{"class":294},[289,1070,1071],{"class":298}," (rise ",[289,1073,785],{"class":294},[289,1075,1076],{"class":298}," run ",[289,1078,760],{"class":294},[289,1080,1081],{"class":342}," 100.0",[289,1083,1084],{"class":298},") ",[289,1086,1087],{"class":294},"if",[289,1089,1076],{"class":298},[289,1091,1092],{"class":294},"else",[289,1094,552],{"class":342},[289,1096,1097,1100,1103,1106,1109,1112,1115],{"class":144,"line":394},[289,1098,1099],{"class":298},"    rows.append({",[289,1101,1102],{"class":335},"\"chainage\"",[289,1104,1105],{"class":298},": chainage, ",[289,1107,1108],{"class":335},"\"height\"",[289,1110,1111],{"class":298},": height, ",[289,1113,1114],{"class":335},"\"gradient_pct\"",[289,1116,1117],{"class":298},": gradient})\n",[289,1119,1120],{"class":144,"line":407},[289,1121,320],{"emptyLinePlaceholder":319},[289,1123,1124,1127,1129,1132,1135,1137,1140,1142,1145,1147,1149,1151,1154,1156,1159,1162,1164,1167,1169,1172,1174,1176,1179,1181,1183],{"class":144,"line":544},[289,1125,1126],{"class":298},"climb ",[289,1128,329],{"class":294},[289,1130,1131],{"class":342}," sum",[289,1133,1134],{"class":298},"(r[",[289,1136,1108],{"class":335},[289,1138,1139],{"class":298},"] ",[289,1141,851],{"class":294},[289,1143,1144],{"class":298}," p[",[289,1146,1108],{"class":335},[289,1148,1139],{"class":298},[289,1150,574],{"class":294},[289,1152,1153],{"class":298}," p, r ",[289,1155,580],{"class":294},[289,1157,1158],{"class":342}," zip",[289,1160,1161],{"class":298},"(rows, rows[",[289,1163,497],{"class":342},[289,1165,1166],{"class":298},":]) ",[289,1168,1087],{"class":294},[289,1170,1171],{"class":298}," r[",[289,1173,1108],{"class":335},[289,1175,1139],{"class":298},[289,1177,1178],{"class":294},">",[289,1180,1144],{"class":298},[289,1182,1108],{"class":335},[289,1184,1185],{"class":298},"])\n",[289,1187,1188,1191,1193,1195,1198,1200,1202,1204,1206,1208,1210,1212,1214,1216,1218,1220,1222,1224,1226,1228,1230,1232,1234,1236,1238],{"class":144,"line":555},[289,1189,1190],{"class":298},"descent ",[289,1192,329],{"class":294},[289,1194,1131],{"class":342},[289,1196,1197],{"class":298},"(p[",[289,1199,1108],{"class":335},[289,1201,1139],{"class":298},[289,1203,851],{"class":294},[289,1205,1171],{"class":298},[289,1207,1108],{"class":335},[289,1209,1139],{"class":298},[289,1211,574],{"class":294},[289,1213,1153],{"class":298},[289,1215,580],{"class":294},[289,1217,1158],{"class":342},[289,1219,1161],{"class":298},[289,1221,497],{"class":342},[289,1223,1166],{"class":298},[289,1225,1087],{"class":294},[289,1227,1171],{"class":298},[289,1229,1108],{"class":335},[289,1231,1139],{"class":298},[289,1233,895],{"class":294},[289,1235,1144],{"class":298},[289,1237,1108],{"class":335},[289,1239,1185],{"class":298},[289,1241,1242,1245,1248,1251,1254,1257,1260,1263,1266,1269,1271,1274,1276,1278],{"class":144,"line":566},[289,1243,1244],{"class":342},"print",[289,1246,1247],{"class":298},"(",[289,1249,1250],{"class":294},"f",[289,1252,1253],{"class":335},"\"total climb ",[289,1255,1256],{"class":342},"{",[289,1258,1259],{"class":298},"climb",[289,1261,1262],{"class":294},":.1f",[289,1264,1265],{"class":342},"}",[289,1267,1268],{"class":335}," m, total descent ",[289,1270,1256],{"class":342},[289,1272,1273],{"class":298},"descent",[289,1275,1262],{"class":294},[289,1277,1265],{"class":342},[289,1279,1280],{"class":335}," m, \"\n",[289,1282,1283,1286,1289,1292,1294,1297,1299,1302,1305,1307,1310,1312,1315,1317,1319,1322],{"class":144,"line":571},[289,1284,1285],{"class":294},"      f",[289,1287,1288],{"class":335},"\"steepest ",[289,1290,1291],{"class":342},"{max",[289,1293,1247],{"class":298},[289,1295,1296],{"class":342},"abs",[289,1298,1134],{"class":298},[289,1300,1301],{"class":335},"'gradient_pct'",[289,1303,1304],{"class":298},"]) ",[289,1306,574],{"class":294},[289,1308,1309],{"class":298}," r ",[289,1311,580],{"class":294},[289,1313,1314],{"class":298}," rows)",[289,1316,1262],{"class":294},[289,1318,1265],{"class":342},[289,1320,1321],{"class":335},"%\"",[289,1323,500],{"class":298},[14,1325,1326,1328,1329,1332,1333,1336],{},[247,1327,420],{}," Gradient as a percentage is what engineering and cycling audiences expect; multiply by ",[286,1330,1331],{},"math.degrees(math.atan(...))"," instead if the audience thinks in degrees. Total climb and descent are the summed positive and negative changes, and they are ",[274,1334,1335],{},"strongly"," dependent on the sampling interval — a finer interval picks up more noise and reports more climb, which is why two tools rarely agree on this number and why quoting the interval alongside it matters. Guarding against a zero run handles duplicate vertices, which densification occasionally produces at segment joins.",[14,1338,1339],{},[29,1340,1343,1346,1349,1352,1356,1363,1367,1371,1375,1379,1382,1385,1388,1391,1395,1398,1401],{"viewBox":1341,"role":32,"ariaLabel":1342,"xmlns":34},"0 0 760 288","The same route sampled at three intervals, showing that total climb increases as the sampling interval gets finer because more small undulations are counted",[36,1344,1345],{},"Total climb depends on how often you look",[40,1347,1348],{},"Sampling the same route every hundred metres, every twenty five metres and every five metres produces increasingly detailed profiles. Each finer interval captures more small rises and falls, so the reported total climb grows even though the terrain has not changed.",[44,1350],{"x":46,"y":46,"width":47,"height":1351,"fill":49},"288",[68,1353,1355],{"x":70,"y":1354,"style":72,"fill":73,"textAnchor":74},"28","Always quote the sampling interval with the total",[44,1357],{"x":1358,"y":103,"width":1359,"height":1360,"rx":1361,"fill":1362,"stroke":99,"style":156},"20","232","196","10","#eff3ff",[68,1364,1366],{"x":109,"y":1365,"style":224,"fill":99,"textAnchor":74},"78","every 100 m",[63,1368],{"d":1369,"fill":93,"stroke":99,"style":1370},"M44 194 L92 158 L140 174 L188 128 L232 146","stroke-width:2.4",[68,1372,1374],{"x":109,"y":1373,"style":88,"fill":66,"textAnchor":74},"228","climb 96 m",[44,1376],{"x":1377,"y":103,"width":1359,"height":1360,"rx":1361,"fill":1378,"stroke":155,"style":156},"264","#eef7f4",[68,1380,1381],{"x":70,"y":1365,"style":224,"fill":155,"textAnchor":74},"every 25 m",[63,1383],{"d":1384,"fill":93,"stroke":155,"style":1370},"M288 196 L312 172 L336 160 L360 176 L384 168 L408 140 L432 126 L456 138 L476 148",[68,1386,1387],{"x":70,"y":1373,"style":88,"fill":66,"textAnchor":74},"climb 118 m",[44,1389],{"x":1390,"y":103,"width":1359,"height":1360,"rx":1361,"fill":220,"stroke":94,"style":156},"508",[68,1392,1394],{"x":1393,"y":1365,"style":224,"fill":94,"textAnchor":74},"624","every 5 m",[63,1396],{"d":1397,"fill":93,"stroke":94,"style":1370},"M532 198 L544 186 L556 190 L568 172 L580 176 L592 158 L604 166 L616 178 L628 168 L640 152 L652 136 L664 142 L676 124 L688 134 L700 142 L716 150",[68,1399,1400],{"x":1393,"y":1373,"style":88,"fill":66,"textAnchor":74},"climb 164 m",[68,1402,1404],{"x":70,"y":1403,"style":88,"fill":82,"textAnchor":74},"274","on a 25 m DEM only the middle figure is supported by the data",[236,1406,1408],{"id":1407},"write-the-profile-out","Write the profile out",[14,1410,1411],{},"A CSV goes into a report; a point layer goes back on the map.",[279,1413,1415],{"className":281,"code":1414,"language":283,"meta":284,"style":284},"import csv\n\nwith open(\"\u002Fdata\u002Foutput\u002Fprofile.csv\", \"w\", newline=\"\") as handle:\n    writer = csv.DictWriter(handle, fieldnames=[\"chainage\", \"height\", \"gradient_pct\"])\n    writer.writeheader()\n    writer.writerows(rows)\n",[286,1416,1417,1424,1428,1466,1496,1501],{"__ignoreMap":284},[289,1418,1419,1421],{"class":144,"line":291},[289,1420,295],{"class":294},[289,1422,1423],{"class":298}," csv\n",[289,1425,1426],{"class":144,"line":302},[289,1427,320],{"emptyLinePlaceholder":319},[289,1429,1430,1433,1436,1438,1441,1444,1447,1449,1453,1455,1458,1460,1463],{"class":144,"line":316},[289,1431,1432],{"class":294},"with",[289,1434,1435],{"class":342}," open",[289,1437,1247],{"class":298},[289,1439,1440],{"class":335},"\"\u002Fdata\u002Foutput\u002Fprofile.csv\"",[289,1442,1443],{"class":298},", ",[289,1445,1446],{"class":335},"\"w\"",[289,1448,1443],{"class":298},[289,1450,1452],{"class":1451},"s9osk","newline",[289,1454,329],{"class":294},[289,1456,1457],{"class":335},"\"\"",[289,1459,1084],{"class":298},[289,1461,1462],{"class":294},"as",[289,1464,1465],{"class":298}," handle:\n",[289,1467,1468,1471,1473,1476,1479,1481,1484,1486,1488,1490,1492,1494],{"class":144,"line":323},[289,1469,1470],{"class":298},"    writer ",[289,1472,329],{"class":294},[289,1474,1475],{"class":298}," csv.DictWriter(handle, ",[289,1477,1478],{"class":1451},"fieldnames",[289,1480,329],{"class":294},[289,1482,1483],{"class":298},"[",[289,1485,1102],{"class":335},[289,1487,1443],{"class":298},[289,1489,1108],{"class":335},[289,1491,1443],{"class":298},[289,1493,1114],{"class":335},[289,1495,1185],{"class":298},[289,1497,1498],{"class":144,"line":348},[289,1499,1500],{"class":298},"    writer.writeheader()\n",[289,1502,1503],{"class":144,"line":353},[289,1504,1505],{"class":298},"    writer.writerows(rows)\n",[14,1507,1508,673,1510,1513,1514,1518,1519,265],{},[247,1509,420],{},[286,1511,1512],{},"newline=\"\""," is required on Windows or every row is separated by a blank line, and it is harmless elsewhere. A CSV of chainage and height opens directly in a spreadsheet and charts in two clicks, which is usually all a report needs. Where the profile should return to the map — to symbolise the steepest sections, for instance — build a memory layer of the sampled points instead and style it with a ",[21,1515,1517],{"href":1516},"\u002Fpyqgis-cartography-visualization\u002Fgraduated-categorized-renderers\u002F","graduated renderer"," on gradient, as covered in ",[21,1520,1522],{"href":1521},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fexport-attribute-table-to-csv-pyqgis\u002F","exporting an attribute table to CSV",[236,1524,1526],{"id":1525},"qgis-version-compatibility","QGIS version compatibility",[14,1528,1529,1530,1532],{},"The examples target ",[247,1531,249],{}," (Python 3.12).",[1534,1535,1536,1552],"table",{},[1537,1538,1539],"thead",{},[1540,1541,1542,1546,1549],"tr",{},[1543,1544,1545],"th",{},"QGIS version",[1543,1547,1548],{},"Python",[1543,1550,1551],{},"Notes",[1553,1554,1555,1570,1581,1594,1605],"tbody",{},[1540,1556,1557,1561,1564],{},[1558,1559,1560],"td",{},"3.16 LTR",[1558,1562,1563],{},"3.7",[1558,1565,1566,1569],{},[286,1567,1568],{},"QgsRasterDataProvider.sample()"," and the densify algorithm present.",[1540,1571,1572,1575,1578],{},[1558,1573,1574],{},"3.22 LTR",[1558,1576,1577],{},"3.9",[1558,1579,1580],{},"Elevation profile tool added to the interface; the API here is unchanged.",[1540,1582,1583,1586,1588],{},[1558,1584,1585],{},"3.28 LTR",[1558,1587,1577],{},[1558,1589,1590,1593],{},[286,1591,1592],{},"QgsProfileRequest"," and the elevation framework introduced for layer-based profiles.",[1540,1595,1596,1599,1602],{},[1558,1597,1598],{},"3.34 LTR",[1558,1600,1601],{},"3.12",[1558,1603,1604],{},"Baseline for this page.",[1540,1606,1607,1610,1612],{},[1558,1608,1609],{},"3.40+",[1558,1611,1601],{},[1558,1613,1614],{},"Profile generators available for mesh and point cloud layers as well as rasters.",[236,1616,1618],{"id":1617},"troubleshooting","Troubleshooting",[241,1620,1621,1630,1636,1642,1653,1659],{},[244,1622,1623,1626,1627,1629],{},[247,1624,1625],{},"Every value is zero."," The route leaves the raster extent, and the ",[286,1628,684],{}," flag was ignored. Check it on every sample.",[244,1631,1632,1635],{},[247,1633,1634],{},"A section of the profile drops to −9999."," Nodata was not compared against. Filter it out rather than plotting it.",[244,1637,1638,1641],{},[247,1639,1640],{},"The profile is a staircase."," The sampling interval is finer than the cell size. Increase it to at least one cell.",[244,1643,1644,1649,1650,1652],{},[247,1645,1646,1648],{},[286,1647,701],{}," returns an empty list."," The feature is multipart. Use ",[286,1651,705],{}," or explode the layer first.",[244,1654,1655,1658],{},[247,1656,1657],{},"Chainage does not match the line's length."," Vertices were dropped at nodata gaps but the chainage was accumulated only for kept points. Accumulate before filtering, as in the example above.",[244,1660,1661,1664],{},[247,1662,1663],{},"The values are wrong by a constant."," The DEM and the route are in different CRSs and the coordinates are being read in the wrong place. Reproject one of them.",[236,1666,1668],{"id":1667},"conclusion","Conclusion",[14,1670,1671,1672,1674],{},"Densify to at least the DEM's cell size, sample through the provider while checking both the ",[286,1673,684],{}," flag and the nodata value, accumulate chainage before filtering, and quote the sampling interval whenever you quote a total climb. The whole thing is a short loop, and the output is a table that answers questions an interactive tool cannot.",[236,1676,1678],{"id":1677},"frequently-asked-questions","Frequently Asked Questions",[14,1680,1681,1684,1686,1687,1690],{},[247,1682,1683],{},"Is there a built-in algorithm for this?",[286,1685,428],{}," plus ",[286,1688,1689],{},"native:rastersampling"," gets you a point layer with values in two calls, which is often enough. The loop above exists because it gives chainage and gradient in one pass and lets you handle nodata explicitly.",[14,1692,1693,1696,1697,1701],{},[247,1694,1695],{},"How do I profile across several rasters?","\nBuild a virtual raster mosaic over them and sample that. The VRT presents a single seamless source, so the loop is unchanged — see ",[21,1698,1700],{"href":1699},"\u002Fspatial-data-processing-automation\u002Fcoordinate-reference-systems\u002Fbatch-reprojecting-raster-datasets\u002F","batch reprojecting raster datasets"," for building consistent inputs.",[14,1703,1704,1707],{},[247,1705,1706],{},"Can I get a profile of something other than elevation?","\nYes, the code is indifferent to what the raster holds. Sampling a rainfall or noise surface along a route works identically; only the axis label changes.",[14,1709,1710,1713],{},[247,1711,1712],{},"Why does my total climb differ from a GPS device's?","\nDevices apply smoothing and a minimum-change threshold before accumulating, precisely because raw accumulation is dominated by noise. Applying a small threshold — ignore changes under a metre — brings the numbers much closer together.",[236,1715,1717],{"id":1716},"related","Related",[241,1719,1720,1725,1731,1737,1743],{},[244,1721,1722,1724],{},[21,1723,24],{"href":23}," — the guide this recipe belongs to",[244,1726,1727],{},[21,1728,1730],{"href":1729},"\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fgenerate-slope-aspect-hillshade-pyqgis\u002F","Generate Slope, Aspect and Hillshade in PyQGIS",[244,1732,1733],{},[21,1734,1736],{"href":1735},"\u002Fspatial-data-processing-automation\u002Fraster-analysis-workflows\u002Fsample-raster-values-at-points-pyqgis\u002F","Sample Raster Values at Points in PyQGIS",[244,1738,1739],{},[21,1740,1742],{"href":1741},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fcalculate-distance-between-features-pyqgis\u002F","Calculate the Distance Between Features in PyQGIS",[244,1744,1745],{},[21,1746,1747],{"href":1521},"Export an Attribute Table to CSV in PyQGIS",[1749,1750,1751],"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":284,"searchDepth":302,"depth":302,"links":1753},[1754,1755,1756,1757,1758,1759,1760,1761,1762,1763,1764],{"id":238,"depth":302,"text":239},{"id":268,"depth":302,"text":269},{"id":432,"depth":302,"text":433},{"id":714,"depth":302,"text":715},{"id":948,"depth":302,"text":949},{"id":1407,"depth":302,"text":1408},{"id":1525,"depth":302,"text":1526},{"id":1617,"depth":302,"text":1618},{"id":1667,"depth":302,"text":1668},{"id":1677,"depth":302,"text":1678},{"id":1716,"depth":302,"text":1717},"Sample a DEM along a route from Python — densify the line, sample values with QgsRasterDataProvider, compute chainage and gradient, and export the profile as a table or a chart.","md",{"slug":1768,"type":1769,"breadcrumb":1770,"datePublished":1771,"dateModified":1771},"extract-elevation-profile-along-line-pyqgis","article","Elevation Profile","2026-08-27","\u002Fspatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fextract-elevation-profile-along-line-pyqgis",{"title":5,"description":1765},"spatial-data-processing-automation\u002Fterrain-and-interpolation-analysis\u002Fextract-elevation-profile-along-line-pyqgis\u002Findex","IjHBgjszdgZYFYJB7vLPClUDqxj9TJmJ9oQECf7hSKI",1787823363915]