[{"data":1,"prerenderedAt":1962},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas":1928,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas":1937,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas":1942,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas":1943},{"id":4,"title":5,"body":6,"dateModified":1894,"datePublished":1894,"description":1895,"extension":1896,"faq":1897,"keyword":1906,"kind":1907,"meta":1908,"navigation":303,"path":1917,"seo":1918,"slug":1921,"stem":1922,"topics":1923,"type":1926,"__hash__":1927},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas\u002Findex.md","Scrape a Web Page Table into Excel with pandas",{"type":7,"value":8,"toc":1879},"minimark",[9,28,40,205,210,243,264,268,281,430,439,443,454,576,591,666,677,773,777,784,861,871,875,889,1021,1051,1224,1228,1239,1366,1381,1385,1388,1683,1695,1699,1718,1722,1775,1779,1787,1791,1813,1817,1823,1829,1835,1844,1848,1875],[10,11,12,13,17,18,21,22,27],"p",{},"Plenty of data that people need in a spreadsheet lives in a table on a web page: exchange rates, a supplier's price list, a public statistics release, an internal status page. Copying and pasting works once and breaks the habit of automating. ",[14,15,16],"code",{},"pandas.read_html"," parses every ",[14,19,20],{},"\u003Ctable>"," on a page into a DataFrame, and with a little cleaning the result is a sheet you can refresh on demand. This page, part of ",[23,24,26],"a",{"href":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002F","Moving Data Between Excel and Databases",", goes from a URL to a formatted workbook, handling the problems real pages have: several tables, two-row headers, footnote markers, dashes and links.",[10,29,30,31,34,35,39],{},"Before scraping any site, check its terms and ",[14,32,33],{},"robots.txt",", prefer an official download or API when one exists — ",[23,36,38],{"href":37},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Ffetch-api-data-into-excel-with-python-requests\u002F","Fetch API Data into Excel with Python requests"," covers that route — and fetch no more often than you need.",[41,42,50,51,50,55,50,59,50,67,50,77,50,88,50,95,50,100,50,106,50,111,50,114,50,120,50,124,50,127,50,131,50,134,50,137,50,143,50,147,50,150,50,154,50,157,50,160,50,166,50,171,50,175,50,179,50,183,50,186,50,189,50,195,50,199,50,202],"svg",{"viewBox":43,"role":44,"ariaLabelledBy":45,"xmlns":48,"style":49},"4 0 752 142","img",[46,47],"scrape-flow-t","scrape-flow-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[52,53,54],"title",{"id":46},"From a URL to a clean sheet",[56,57,58],"desc",{"id":47},"Five steps. Fetch the page with requests. Parse every table with read_html. Pick the table by id or text. Clean the headers, footnotes and dashes. Save to Excel with the source and time.",[60,61],"rect",{"x":62,"y":63,"width":64,"height":65,"fill":66},"4","0","752","142","#ffffff",[60,68],{"x":69,"y":70,"width":71,"height":72,"rx":73,"fill":74,"stroke":75,"style":76},"20.0","16.0","115.2","110.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[78,79,81],"g",{"transform":80},"translate(65.6,37.8) scale(1.000)",[82,83],"path",{"d":84,"fill":85,"stroke":86,"style":87},"M12 3a9 9 0 1 0 .01 0z M3 12h18 M12 3c3.2 3 3.2 15 0 18 M12 3c-3.2 3-3.2 15 0 18","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[89,90,94],"text",{"x":91,"y":92,"style":93},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Fetch",[89,96,99],{"x":91,"y":97,"style":98},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","requests.get",[101,102],"line",{"x1":103,"y1":104,"x2":105,"y2":104,"stroke":75,"style":76},"140.2","71.2","162.2",[107,108],"polygon",{"points":109,"fill":110},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[60,112],{"x":113,"y":70,"width":71,"height":72,"rx":73,"fill":74,"stroke":75,"style":76},"171.2",[78,115,117],{"transform":116},"translate(216.8,37.8) scale(1.000)",[82,118],{"d":119,"fill":85,"stroke":86,"style":87},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14",[89,121,123],{"x":122,"y":92,"style":93},"228.8","Parse",[89,125,126],{"x":122,"y":97,"style":98},"read_html",[101,128],{"x1":129,"y1":104,"x2":130,"y2":104,"stroke":75,"style":76},"291.4","313.4",[107,132],{"points":133,"fill":110},"317.4,71.2 308.4,76.2 308.4,66.2",[60,135],{"x":136,"y":70,"width":71,"height":72,"rx":73,"fill":74,"stroke":75,"style":76},"322.4",[78,138,140],{"transform":139},"translate(368.0,37.8) scale(1.000)",[82,141],{"d":142,"fill":85,"stroke":86,"style":87},"M3 5h18l-7 8v6l-4 2v-8z",[89,144,146],{"x":145,"y":92,"style":93},"380.0","Pick",[89,148,149],{"x":145,"y":97,"style":98},"attrs or match",[101,151],{"x1":152,"y1":104,"x2":153,"y2":104,"stroke":75,"style":76},"442.6","464.6",[107,155],{"points":156,"fill":110},"468.6,71.2 459.6,76.2 459.6,66.2",[60,158],{"x":159,"y":70,"width":71,"height":72,"rx":73,"fill":74,"stroke":75,"style":76},"473.6",[78,161,163],{"transform":162},"translate(519.2,29.0) scale(1.000)",[82,164],{"d":165,"fill":85,"stroke":86,"style":87},"M12 9a3 3 0 1 0 .01 0z M12 2.5v3 M12 18.5v3 M2.5 12h3 M18.5 12h3 M5.3 5.3l2.1 2.1 M16.6 16.6l2.1 2.1 M5.3 18.7l2.1-2.1 M16.6 7.4l2.1-2.1",[89,167,170],{"x":168,"y":169,"style":93},"531.2","75.5","Clean",[89,172,174],{"x":168,"y":173,"style":98},"95.0","headers and",[89,176,178],{"x":168,"y":177,"style":98},"112.5","numbers",[101,180],{"x1":181,"y1":104,"x2":182,"y2":104,"stroke":75,"style":76},"593.8","615.8",[107,184],{"points":185,"fill":110},"619.8,71.2 610.8,76.2 610.8,66.2",[60,187],{"x":188,"y":70,"width":71,"height":72,"rx":73,"fill":74,"stroke":75,"style":76},"624.8",[78,190,192],{"transform":191},"translate(670.4,29.0) scale(1.000)",[82,193],{"d":194,"fill":85,"stroke":86,"style":87},"M4 4h16v16H4z M4 9h16 M4 14h16 M10 4v16",[89,196,198],{"x":197,"y":169,"style":93},"682.4","Save",[89,200,201],{"x":197,"y":173,"style":98},"with source",[89,203,204],{"x":197,"y":177,"style":98},"note",[206,207,209],"h2",{"id":208},"prerequisites","Prerequisites",[211,212,217],"pre",{"className":213,"code":214,"language":215,"meta":216,"style":216},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas requests lxml openpyxl\n","bash","",[14,218,219],{"__ignoreMap":216},[220,221,223,227,231,234,237,240],"span",{"class":101,"line":222},1,[220,224,226],{"class":225},"sMTad","pip",[220,228,230],{"class":229},"srMev"," install",[220,232,233],{"class":229}," pandas",[220,235,236],{"class":229}," requests",[220,238,239],{"class":229}," lxml",[220,241,242],{"class":229}," openpyxl\n",[10,244,245,247,248,251,252,255,256,259,260,263],{},[14,246,126],{}," needs an HTML parser: ",[14,249,250],{},"lxml"," is fastest; ",[14,253,254],{},"beautifulsoup4"," with ",[14,257,258],{},"html5lib"," copes better with badly broken markup and is used with ",[14,261,262],{},"flavor=\"bs4\"",". The example page has a navigation table, an exchange-rate table with a two-row header, links and a footnote, and a small sales table.",[206,265,267],{"id":266},"step-1-fetch-the-page","Step 1 — Fetch the page",[10,269,270,272,273,276,277,280],{},[14,271,126],{}," can take a URL directly, but fetching with ",[14,274,275],{},"requests"," first gives you control over headers, timeouts and errors — and many sites refuse requests without a browser-like ",[14,278,279],{},"User-Agent",":",[211,282,286],{"className":283,"code":284,"language":285,"meta":216,"style":216},"language-python shiki shiki-themes github-light github-dark-high-contrast","import requests\n\nresponse = requests.get(url, headers={\"User-Agent\": \"rates-report\u002F1.0 (finance@example.com)\"}, timeout=30)\nresponse.raise_for_status()\nresponse.encoding = response.encoding or \"utf-8\"\nhtml = response.text\nwith open(\"rates.html\", \"w\", encoding=\"utf-8\") as fh:\n    fh.write(html)\n","python",[14,287,288,298,305,350,356,373,384,424],{"__ignoreMap":216},[220,289,290,294],{"class":101,"line":222},[220,291,293],{"class":292},"s-kum","import",[220,295,297],{"class":296},"skGVy"," requests\n",[220,299,301],{"class":101,"line":300},2,[220,302,304],{"emptyLinePlaceholder":303},true,"\n",[220,306,308,311,314,317,321,323,326,329,332,335,338,341,343,347],{"class":101,"line":307},3,[220,309,310],{"class":296},"response ",[220,312,313],{"class":292},"=",[220,315,316],{"class":296}," requests.get(url, ",[220,318,320],{"class":319},"sa561","headers",[220,322,313],{"class":292},[220,324,325],{"class":296},"{",[220,327,328],{"class":229},"\"User-Agent\"",[220,330,331],{"class":296},": ",[220,333,334],{"class":229},"\"rates-report\u002F1.0 (finance@example.com)\"",[220,336,337],{"class":296},"}, ",[220,339,340],{"class":319},"timeout",[220,342,313],{"class":292},[220,344,346],{"class":345},"sP0c6","30",[220,348,349],{"class":296},")\n",[220,351,353],{"class":101,"line":352},4,[220,354,355],{"class":296},"response.raise_for_status()\n",[220,357,359,362,364,367,370],{"class":101,"line":358},5,[220,360,361],{"class":296},"response.encoding ",[220,363,313],{"class":292},[220,365,366],{"class":296}," response.encoding ",[220,368,369],{"class":292},"or",[220,371,372],{"class":229}," \"utf-8\"\n",[220,374,376,379,381],{"class":101,"line":375},6,[220,377,378],{"class":296},"html ",[220,380,313],{"class":292},[220,382,383],{"class":296}," response.text\n",[220,385,387,390,393,396,399,402,405,407,410,412,415,418,421],{"class":101,"line":386},7,[220,388,389],{"class":292},"with",[220,391,392],{"class":345}," open",[220,394,395],{"class":296},"(",[220,397,398],{"class":229},"\"rates.html\"",[220,400,401],{"class":296},", ",[220,403,404],{"class":229},"\"w\"",[220,406,401],{"class":296},[220,408,409],{"class":319},"encoding",[220,411,313],{"class":292},[220,413,414],{"class":229},"\"utf-8\"",[220,416,417],{"class":296},") ",[220,419,420],{"class":292},"as",[220,422,423],{"class":296}," fh:\n",[220,425,427],{"class":101,"line":426},8,[220,428,429],{"class":296},"    fh.write(html)\n",[10,431,432,435,436,438],{},[14,433,434],{},"raise_for_status()"," stops the script on a 403 or 500 instead of parsing an error page. Saving the HTML you fetched is cheap and lets you rerun the parsing — or prove what the page said — without fetching again. An identifying ",[14,437,279],{}," with a contact address is politer than impersonating a browser.",[206,440,442],{"id":441},"step-2-parse-every-table-and-find-the-one-you-need","Step 2 — Parse every table and find the one you need",[10,444,445,447,448,450,451,280],{},[14,446,126],{}," returns a list with one DataFrame per ",[14,449,20],{},". In pandas 3, a plain string is treated as a file path or URL, so wrap HTML text in ",[14,452,453],{},"io.StringIO",[211,455,457],{"className":283,"code":456,"language":285,"meta":216,"style":216},"from io import StringIO\nimport pandas as pd\n\ntry:\n    pd.read_html(html)\nexcept FileNotFoundError:\n    print(\"a plain string is read as a path\")\n\ntables = pd.read_html(StringIO(html))\nfor i, t in enumerate(tables):\n    print(i, t.shape, list(t.columns)[:3])\n",[14,458,459,472,484,488,496,501,511,523,527,538,556],{"__ignoreMap":216},[220,460,461,464,467,469],{"class":101,"line":222},[220,462,463],{"class":292},"from",[220,465,466],{"class":296}," io ",[220,468,293],{"class":292},[220,470,471],{"class":296}," StringIO\n",[220,473,474,476,479,481],{"class":101,"line":300},[220,475,293],{"class":292},[220,477,478],{"class":296}," pandas ",[220,480,420],{"class":292},[220,482,483],{"class":296}," pd\n",[220,485,486],{"class":101,"line":307},[220,487,304],{"emptyLinePlaceholder":303},[220,489,490,493],{"class":101,"line":352},[220,491,492],{"class":292},"try",[220,494,495],{"class":296},":\n",[220,497,498],{"class":101,"line":358},[220,499,500],{"class":296},"    pd.read_html(html)\n",[220,502,503,506,509],{"class":101,"line":375},[220,504,505],{"class":292},"except",[220,507,508],{"class":345}," FileNotFoundError",[220,510,495],{"class":296},[220,512,513,516,518,521],{"class":101,"line":386},[220,514,515],{"class":345},"    print",[220,517,395],{"class":296},[220,519,520],{"class":229},"\"a plain string is read as a path\"",[220,522,349],{"class":296},[220,524,525],{"class":101,"line":426},[220,526,304],{"emptyLinePlaceholder":303},[220,528,530,533,535],{"class":101,"line":529},9,[220,531,532],{"class":296},"tables ",[220,534,313],{"class":292},[220,536,537],{"class":296}," pd.read_html(StringIO(html))\n",[220,539,541,544,547,550,553],{"class":101,"line":540},10,[220,542,543],{"class":292},"for",[220,545,546],{"class":296}," i, t ",[220,548,549],{"class":292},"in",[220,551,552],{"class":345}," enumerate",[220,554,555],{"class":296},"(tables):\n",[220,557,559,561,564,567,570,573],{"class":101,"line":558},11,[220,560,515],{"class":345},[220,562,563],{"class":296},"(i, t.shape, ",[220,565,566],{"class":345},"list",[220,568,569],{"class":296},"(t.columns)[:",[220,571,572],{"class":345},"3",[220,574,575],{"class":296},"])\n",[10,577,578,579,582,583,586,587,590],{},"Three tables: the navigation bar (pages often use tables for layout), the rates and the sales. Relying on position — ",[14,580,581],{},"tables[1]"," — breaks the day the site adds a banner table above the one you want. Select by content instead: ",[14,584,585],{},"attrs"," matches the table's HTML attributes, and ",[14,588,589],{},"match"," keeps tables containing a piece of text:",[211,592,594],{"className":283,"code":593,"language":285,"meta":216,"style":216},"rates = pd.read_html(StringIO(html), attrs={\"id\": \"rates\"})[0]\nsame = pd.read_html(StringIO(html), match=\"Rate per EUR\")[0]\nprint(rates.equals(same))\nprint(rates)\n",[14,595,596,628,651,659],{"__ignoreMap":216},[220,597,598,601,603,606,608,610,612,615,617,620,623,625],{"class":101,"line":222},[220,599,600],{"class":296},"rates ",[220,602,313],{"class":292},[220,604,605],{"class":296}," pd.read_html(StringIO(html), ",[220,607,585],{"class":319},[220,609,313],{"class":292},[220,611,325],{"class":296},[220,613,614],{"class":229},"\"id\"",[220,616,331],{"class":296},[220,618,619],{"class":229},"\"rates\"",[220,621,622],{"class":296},"})[",[220,624,63],{"class":345},[220,626,627],{"class":296},"]\n",[220,629,630,633,635,637,639,641,644,647,649],{"class":101,"line":300},[220,631,632],{"class":296},"same ",[220,634,313],{"class":292},[220,636,605],{"class":296},[220,638,589],{"class":319},[220,640,313],{"class":292},[220,642,643],{"class":229},"\"Rate per EUR\"",[220,645,646],{"class":296},")[",[220,648,63],{"class":345},[220,650,627],{"class":296},[220,652,653,656],{"class":101,"line":307},[220,654,655],{"class":345},"print",[220,657,658],{"class":296},"(rates.equals(same))\n",[220,660,661,663],{"class":101,"line":352},[220,662,655],{"class":345},[220,664,665],{"class":296},"(rates)\n",[10,667,668,669,672,673,676],{},"Both return the same table. An ",[14,670,671],{},"id"," is the steadiest hook when the page has one; look for it with your browser's \"Inspect\" tool. When reading a saved file rather than text, pass ",[14,674,675],{},"encoding=\"utf-8\""," — without it, the dash in the Swiss franc row comes out as garbled characters.",[41,678,50,683,50,686,50,689,50,692,50,698,50,703,50,709,50,712,50,716,50,721,50,727,50,729,50,732,50,734,50,737,50,740,50,744,50,746,50,750,50,752,50,755,50,758,50,762,50,764,50,768,50,770],{"viewBox":679,"role":44,"ariaLabelledBy":680,"xmlns":48,"style":49},"4 0 752 174",[681,682],"scrape-pick-t","scrape-pick-d",[52,684,685],{"id":681},"Ways to choose a table from read_html",[56,687,688],{"id":682},"A table of selection methods. Position, such as tables[1], breaks when the page adds a table. match with text keeps tables containing that text and survives layout changes but may match more than one. attrs with an id matches the table's HTML id and is the most stable when the page provides one.",[60,690],{"x":62,"y":63,"width":64,"height":691,"fill":66},"174",[60,693],{"x":69,"y":70,"width":694,"height":695,"rx":63,"fill":66,"stroke":696,"style":697},"124.5","40.0","var(--line,#cdd5e6)","stroke-width:1px",[60,699],{"x":700,"y":70,"width":701,"height":695,"rx":63,"fill":74,"stroke":75,"style":702},"144.5","297.7","stroke-width:1.2px",[89,704,708],{"x":705,"y":706,"style":707},"293.4","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Example",[60,710],{"x":711,"y":70,"width":701,"height":695,"rx":63,"fill":74,"stroke":75,"style":702},"442.3",[89,713,715],{"x":714,"y":706,"style":707},"591.1","Survives page changes",[60,717],{"x":69,"y":718,"width":694,"height":719,"rx":63,"fill":720,"stroke":696,"style":697},"56.0","34.0","#e7ebef",[89,722,726],{"x":723,"y":724,"style":725},"32.0","78.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","Position",[60,728],{"x":700,"y":718,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,730,581],{"x":705,"y":731,"style":98},"77.0",[60,733],{"x":711,"y":718,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,735,736],{"x":714,"y":731,"style":98},"no — shifts with new tables",[60,738],{"x":69,"y":739,"width":694,"height":719,"rx":63,"fill":720,"stroke":696,"style":697},"90.0",[89,741,743],{"x":723,"y":742,"style":725},"112.0","Text match",[60,745],{"x":700,"y":739,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,747,749],{"x":705,"y":748,"style":98},"111.0","match=\"Rate per EUR\"",[60,751],{"x":711,"y":739,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,753,754],{"x":714,"y":748,"style":98},"mostly — may match several",[60,756],{"x":69,"y":757,"width":694,"height":719,"rx":63,"fill":720,"stroke":696,"style":697},"124.0",[89,759,761],{"x":723,"y":760,"style":725},"146.0","HTML attribute",[60,763],{"x":700,"y":757,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,765,767],{"x":705,"y":766,"style":98},"145.0","attrs={\"id\": \"rates\"}",[60,769],{"x":711,"y":757,"width":701,"height":719,"rx":63,"fill":66,"stroke":696,"style":697},[89,771,772],{"x":714,"y":766,"style":98},"yes — when an id exists",[206,774,776],{"id":775},"step-3-flatten-a-two-row-header","Step 3 — Flatten a two-row header",[10,778,779,780,783],{},"The rates table has a grouped header — \"Rate per EUR\" spanning \"Buy\" and \"Sell\" — so pandas builds a two-level column index, and the Currency column, spanning both rows, appears as ",[14,781,782],{},"(\"Currency\", \"Currency\")",". Excel wants one row of headers; join the levels and drop the repeats:",[211,785,787],{"className":283,"code":786,"language":285,"meta":216,"style":216},"print(rates.columns.tolist())\nrates.columns = [\" \".join(dict.fromkeys(level for level in col if not str(level).startswith(\"Unnamed\"))) for col in rates.columns]\nprint(rates.columns.tolist())\n",[14,788,789,796,855],{"__ignoreMap":216},[220,790,791,793],{"class":101,"line":222},[220,792,655],{"class":345},[220,794,795],{"class":296},"(rates.columns.tolist())\n",[220,797,798,801,803,806,809,812,815,818,820,823,825,828,831,834,837,840,843,846,848,850,852],{"class":101,"line":300},[220,799,800],{"class":296},"rates.columns ",[220,802,313],{"class":292},[220,804,805],{"class":296}," [",[220,807,808],{"class":229},"\" \"",[220,810,811],{"class":296},".join(",[220,813,814],{"class":345},"dict",[220,816,817],{"class":296},".fromkeys(level ",[220,819,543],{"class":292},[220,821,822],{"class":296}," level ",[220,824,549],{"class":292},[220,826,827],{"class":296}," col ",[220,829,830],{"class":292},"if",[220,832,833],{"class":292}," not",[220,835,836],{"class":345}," str",[220,838,839],{"class":296},"(level).startswith(",[220,841,842],{"class":229},"\"Unnamed\"",[220,844,845],{"class":296},"))) ",[220,847,543],{"class":292},[220,849,827],{"class":296},[220,851,549],{"class":292},[220,853,854],{"class":296}," rates.columns]\n",[220,856,857,859],{"class":101,"line":307},[220,858,655],{"class":345},[220,860,795],{"class":296},[10,862,863,866,867,870],{},[14,864,865],{},"dict.fromkeys"," removes the duplicate \"Currency\" while keeping order, and the ",[14,868,869],{},"Unnamed"," filter handles headers where one level was empty. For a simple one-row header, the columns are already plain strings and this step is unnecessary.",[206,872,874],{"id":873},"step-4-turn-text-into-numbers","Step 4 — Turn text into numbers",[10,876,877,878,881,882,885,886,888],{},"Look at the column types: the Sell column is numeric, but Buy is text, because one cell says ",[14,879,880],{},"0.8561[a]"," — a footnote marker — and another holds a dash meaning \"no rate\". The thousands separator in the yen row was handled already: ",[14,883,884],{},"thousands=\",\""," is ",[14,887,126],{},"'s default. Strip anything that is not part of a number, then convert:",[211,890,892],{"className":283,"code":891,"language":285,"meta":216,"style":216},"print(rates.dtypes.to_dict())\n\nfor col in [\"Rate per EUR Buy\", \"Rate per EUR Sell\"]:\n    cleaned = rates[col].astype(str).str.replace(r\"\\[\\w+\\]\", \"\", regex=True).str.replace(\",\", \"\")\n    rates[col] = pd.to_numeric(cleaned, errors=\"coerce\")\nprint(rates)\nprint(rates.dtypes.to_dict())\n",[14,893,894,901,905,926,989,1009,1015],{"__ignoreMap":216},[220,895,896,898],{"class":101,"line":222},[220,897,655],{"class":345},[220,899,900],{"class":296},"(rates.dtypes.to_dict())\n",[220,902,903],{"class":101,"line":300},[220,904,304],{"emptyLinePlaceholder":303},[220,906,907,909,911,913,915,918,920,923],{"class":101,"line":307},[220,908,543],{"class":292},[220,910,827],{"class":296},[220,912,549],{"class":292},[220,914,805],{"class":296},[220,916,917],{"class":229},"\"Rate per EUR Buy\"",[220,919,401],{"class":296},[220,921,922],{"class":229},"\"Rate per EUR Sell\"",[220,924,925],{"class":296},"]:\n",[220,927,928,931,933,936,939,942,945,948,952,955,958,961,963,965,968,970,973,975,978,980,983,985,987],{"class":101,"line":352},[220,929,930],{"class":296},"    cleaned ",[220,932,313],{"class":292},[220,934,935],{"class":296}," rates[col].astype(",[220,937,938],{"class":345},"str",[220,940,941],{"class":296},").str.replace(",[220,943,944],{"class":292},"r",[220,946,947],{"class":229},"\"",[220,949,951],{"class":950},"s_b0D","\\[",[220,953,954],{"class":345},"\\w",[220,956,957],{"class":292},"+",[220,959,960],{"class":950},"\\]",[220,962,947],{"class":229},[220,964,401],{"class":296},[220,966,967],{"class":229},"\"\"",[220,969,401],{"class":296},[220,971,972],{"class":319},"regex",[220,974,313],{"class":292},[220,976,977],{"class":345},"True",[220,979,941],{"class":296},[220,981,982],{"class":229},"\",\"",[220,984,401],{"class":296},[220,986,967],{"class":229},[220,988,349],{"class":296},[220,990,991,994,996,999,1002,1004,1007],{"class":101,"line":358},[220,992,993],{"class":296},"    rates[col] ",[220,995,313],{"class":292},[220,997,998],{"class":296}," pd.to_numeric(cleaned, ",[220,1000,1001],{"class":319},"errors",[220,1003,313],{"class":292},[220,1005,1006],{"class":229},"\"coerce\"",[220,1008,349],{"class":296},[220,1010,1011,1013],{"class":101,"line":375},[220,1012,655],{"class":345},[220,1014,665],{"class":296},[220,1016,1017,1019],{"class":101,"line":386},[220,1018,655],{"class":345},[220,1020,900],{"class":296},[10,1022,1023,1024,1027,1028,1031,1032,1035,1036,1039,1040,1043,1044,1047,1048,1050],{},"The footnote is gone, the dash has become ",[14,1025,1026],{},"NaN"," — an empty cell in Excel — and both columns are floats. Check ",[14,1029,1030],{},"rates[col].isna()"," against the raw column afterwards so a value you did not expect, such as ",[14,1033,1034],{},"n\u002Fa"," or ",[14,1037,1038],{},"1.08 (est.)",", is noticed rather than silently blanked. Pages that use a decimal comma need ",[14,1041,1042],{},"decimal=\",\""," and ",[14,1045,1046],{},"thousands=\".\""," in ",[14,1049,126],{}," instead.",[41,1052,50,1058,50,1061,50,1064,50,1069,50,1073,50,1077,50,1082,50,1086,50,1091,50,1093,50,1097,50,1100,50,1103,50,1105,50,1109,50,1112,50,1115,50,1117,50,1119,50,1122,50,1125,50,1127,50,1130,50,1133,50,1137,50,1139,50,1142,50,1145,50,1149,50,1152,50,1154,50,1157,50,1159,50,1161,50,1163,50,1165,50,1167,50,1169,50,1171,50,1173,50,1175,50,1178,50,1180,50,1182,50,1184,50,1187,50,1189,50,1191,50,1193,50,1199,50,1203,50,1208,50,1211,50,1218],{"viewBox":1053,"role":44,"ariaLabelledBy":1054,"xmlns":48,"style":1057},"201 0 357 220",[1055,1056],"scrape-clean-t","scrape-clean-d","width:100%;max-width:357px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[52,1059,1060],{"id":1055},"The Buy column before and after cleaning",[56,1062,1063],{"id":1056},"Left, the Buy column as read_html returns it: text values 1.0842, 0.8561[a], 1612.40 and a dash. Right, after stripping the footnote marker and converting with to_numeric: numbers 1.0842, 0.8561 and 1612.4, and an empty cell where the dash was.",[60,1065],{"x":1066,"y":63,"width":1067,"height":1068,"fill":66},"201","357","220",[60,1070],{"x":1071,"y":695,"width":723,"height":1072,"rx":63,"fill":720,"stroke":696,"style":697},"217.8","24.0",[60,1074],{"x":1075,"y":695,"width":1076,"height":1072,"rx":63,"fill":720,"stroke":696,"style":697},"249.8","83.2",[89,1078,1081],{"x":129,"y":1079,"style":1080},"57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[60,1083],{"x":1071,"y":1084,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},"64.0","28.0",[89,1087,1090],{"x":1088,"y":1089,"style":1080},"233.8","83.0","1",[60,1092],{"x":1075,"y":1084,"width":1076,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1094,1096],{"x":129,"y":1089,"style":1095},"font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Buy",[60,1098],{"x":1071,"y":1099,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},"92.0",[89,1101,1102],{"x":1088,"y":748,"style":1080},"2",[60,1104],{"x":1075,"y":1099,"width":1076,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1106,1108],{"x":129,"y":748,"style":1107},"font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","1.0842",[60,1110],{"x":1071,"y":1111,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},"120.0",[89,1113,572],{"x":1088,"y":1114,"style":1080},"139.0",[60,1116],{"x":1075,"y":1111,"width":1076,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1118,880],{"x":129,"y":1114,"style":1107},[60,1120],{"x":1071,"y":1121,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},"148.0",[89,1123,62],{"x":1088,"y":1124,"style":1080},"167.0",[60,1126],{"x":1075,"y":1121,"width":1076,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1128,1129],{"x":129,"y":1124,"style":1107},"1612.40",[60,1131],{"x":1071,"y":1132,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},"176.0",[89,1134,1136],{"x":1088,"y":1135,"style":1080},"195.0","5",[60,1138],{"x":1075,"y":1132,"width":1076,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1140,1141],{"x":129,"y":1135,"style":1107},"—",[60,1143],{"x":1144,"y":695,"width":723,"height":1072,"rx":63,"fill":720,"stroke":696,"style":697},"443.4",[60,1146],{"x":1147,"y":695,"width":1148,"height":1072,"rx":63,"fill":720,"stroke":696,"style":697},"475.4","66.8",[89,1150,1081],{"x":1151,"y":1079,"style":1080},"508.8",[60,1153],{"x":1144,"y":1084,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},[89,1155,1090],{"x":1156,"y":1089,"style":1080},"459.4",[60,1158],{"x":1147,"y":1084,"width":1148,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1160,1096],{"x":1151,"y":1089,"style":1095},[60,1162],{"x":1144,"y":1099,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},[89,1164,1102],{"x":1156,"y":748,"style":1080},[60,1166],{"x":1147,"y":1099,"width":1148,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1168,1108],{"x":1151,"y":748,"style":1107},[60,1170],{"x":1144,"y":1111,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},[89,1172,572],{"x":1156,"y":1114,"style":1080},[60,1174],{"x":1147,"y":1111,"width":1148,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1176,1177],{"x":1151,"y":1114,"style":1107},"0.8561",[60,1179],{"x":1144,"y":1121,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},[89,1181,62],{"x":1156,"y":1124,"style":1080},[60,1183],{"x":1147,"y":1121,"width":1148,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1185,1186],{"x":1151,"y":1124,"style":1107},"1612.4",[60,1188],{"x":1144,"y":1132,"width":723,"height":1085,"rx":63,"fill":720,"stroke":696,"style":697},[89,1190,1136],{"x":1156,"y":1135,"style":1080},[60,1192],{"x":1147,"y":1132,"width":1148,"height":1085,"rx":63,"fill":66,"stroke":696,"style":697},[89,1194,1198],{"x":1195,"y":1196,"style":1197},"275.4","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","before",[89,1200,1202],{"x":1201,"y":1196,"style":1197},"492.8","after",[101,1204],{"x1":1205,"y1":1206,"x2":1207,"y2":1206,"stroke":75,"style":76},"343.0","122.0","429.4",[107,1209],{"points":1210,"fill":110},"433.4,122.0 424.4,127.0 424.4,117.0",[60,1212],{"x":1213,"y":1214,"width":1215,"height":1216,"rx":1217,"fill":66,"stroke":696,"style":702},"360.0","89.0","56.3","22.0","11",[89,1219,1223],{"x":1220,"y":1221,"style":1222},"388.2","104.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","clean",[206,1225,1227],{"id":1226},"step-5-keep-the-links","Step 5 — Keep the links",[10,1229,1230,1231,1234,1235,1238],{},"A table's links are often as useful as its text — the detail page for each currency, product or filing. ",[14,1232,1233],{},"extract_links=\"body\""," returns each body cell as a ",[14,1236,1237],{},"(text, href)"," tuple; split them into two columns and turn relative links into full URLs:",[211,1240,1242],{"className":283,"code":1241,"language":285,"meta":216,"style":216},"from urllib.parse import urljoin\n\nlinked = pd.read_html(StringIO(html), attrs={\"id\": \"rates\"}, extract_links=\"body\")[0]\npairs = linked.iloc[:, 0]\nrates[\"Detail page\"] = [urljoin(url, href) if href else None for _, href in pairs]\nprint(rates[[\"Currency\", \"Detail page\"]])\n",[14,1243,1244,1256,1260,1297,1311,1349],{"__ignoreMap":216},[220,1245,1246,1248,1251,1253],{"class":101,"line":222},[220,1247,463],{"class":292},[220,1249,1250],{"class":296}," urllib.parse ",[220,1252,293],{"class":292},[220,1254,1255],{"class":296}," urljoin\n",[220,1257,1258],{"class":101,"line":300},[220,1259,304],{"emptyLinePlaceholder":303},[220,1261,1262,1265,1267,1269,1271,1273,1275,1277,1279,1281,1283,1286,1288,1291,1293,1295],{"class":101,"line":307},[220,1263,1264],{"class":296},"linked ",[220,1266,313],{"class":292},[220,1268,605],{"class":296},[220,1270,585],{"class":319},[220,1272,313],{"class":292},[220,1274,325],{"class":296},[220,1276,614],{"class":229},[220,1278,331],{"class":296},[220,1280,619],{"class":229},[220,1282,337],{"class":296},[220,1284,1285],{"class":319},"extract_links",[220,1287,313],{"class":292},[220,1289,1290],{"class":229},"\"body\"",[220,1292,646],{"class":296},[220,1294,63],{"class":345},[220,1296,627],{"class":296},[220,1298,1299,1302,1304,1307,1309],{"class":101,"line":352},[220,1300,1301],{"class":296},"pairs ",[220,1303,313],{"class":292},[220,1305,1306],{"class":296}," linked.iloc[:, ",[220,1308,63],{"class":345},[220,1310,627],{"class":296},[220,1312,1313,1316,1319,1322,1324,1327,1329,1332,1335,1338,1341,1344,1346],{"class":101,"line":358},[220,1314,1315],{"class":296},"rates[",[220,1317,1318],{"class":229},"\"Detail page\"",[220,1320,1321],{"class":296},"] ",[220,1323,313],{"class":292},[220,1325,1326],{"class":296}," [urljoin(url, href) ",[220,1328,830],{"class":292},[220,1330,1331],{"class":296}," href ",[220,1333,1334],{"class":292},"else",[220,1336,1337],{"class":345}," None",[220,1339,1340],{"class":292}," for",[220,1342,1343],{"class":296}," _, href ",[220,1345,549],{"class":292},[220,1347,1348],{"class":296}," pairs]\n",[220,1350,1351,1353,1356,1359,1361,1363],{"class":101,"line":375},[220,1352,655],{"class":345},[220,1354,1355],{"class":296},"(rates[[",[220,1357,1358],{"class":229},"\"Currency\"",[220,1360,401],{"class":296},[220,1362,1318],{"class":229},[220,1364,1365],{"class":296},"]])\n",[10,1367,1368,1369,885,1372,1375,1376,1380],{},"The Swiss franc has no link, so its ",[14,1370,1371],{},"href",[14,1373,1374],{},"None"," and the column stays empty. ",[23,1377,1379],{"href":1378},"\u002Fgetting-started-with-python-excel-automation\u002Fwriting-dataframes-to-excel-with-pandas\u002Fwrite-clickable-links-from-a-dataframe-to-excel\u002F","Write Clickable Links from a DataFrame to Excel"," turns these URLs into clickable hyperlinks in the workbook.",[206,1382,1384],{"id":1383},"step-6-save-the-table-with-its-source","Step 6 — Save the table with its source",[10,1386,1387],{},"Scraped figures need provenance: where they came from and when. Write the table, and put the source URL and fetch time above it so anyone reading the sheet can check it:",[211,1389,1391],{"className":283,"code":1390,"language":285,"meta":216,"style":216},"from datetime import datetime\n\nfetched = datetime(2026, 9, 25, 9, 0)\nwith pd.ExcelWriter(\"exchange_rates.xlsx\", engine=\"openpyxl\") as writer:\n    rates.to_excel(writer, sheet_name=\"Rates\", index=False, startrow=3)\n    ws = writer.sheets[\"Rates\"]\n    ws[\"A1\"] = \"Exchange rates\"\n    ws[\"A2\"] = f\"Source: {url} — fetched {fetched:%Y-%m-%d %H:%M}\"\n    for col, width in zip(\"ABCD\", (18, 18, 18, 36)):\n        ws.column_dimensions[col].width = width\n\nprint(pd.read_excel(\"exchange_rates.xlsx\", skiprows=3).to_string(index=False))\n",[14,1392,1393,1405,1409,1442,1469,1503,1517,1532,1598,1638,1648,1652],{"__ignoreMap":216},[220,1394,1395,1397,1400,1402],{"class":101,"line":222},[220,1396,463],{"class":292},[220,1398,1399],{"class":296}," datetime ",[220,1401,293],{"class":292},[220,1403,1404],{"class":296}," datetime\n",[220,1406,1407],{"class":101,"line":300},[220,1408,304],{"emptyLinePlaceholder":303},[220,1410,1411,1414,1416,1419,1422,1424,1427,1429,1432,1434,1436,1438,1440],{"class":101,"line":307},[220,1412,1413],{"class":296},"fetched ",[220,1415,313],{"class":292},[220,1417,1418],{"class":296}," datetime(",[220,1420,1421],{"class":345},"2026",[220,1423,401],{"class":296},[220,1425,1426],{"class":345},"9",[220,1428,401],{"class":296},[220,1430,1431],{"class":345},"25",[220,1433,401],{"class":296},[220,1435,1426],{"class":345},[220,1437,401],{"class":296},[220,1439,63],{"class":345},[220,1441,349],{"class":296},[220,1443,1444,1446,1449,1452,1454,1457,1459,1462,1464,1466],{"class":101,"line":352},[220,1445,389],{"class":292},[220,1447,1448],{"class":296}," pd.ExcelWriter(",[220,1450,1451],{"class":229},"\"exchange_rates.xlsx\"",[220,1453,401],{"class":296},[220,1455,1456],{"class":319},"engine",[220,1458,313],{"class":292},[220,1460,1461],{"class":229},"\"openpyxl\"",[220,1463,417],{"class":296},[220,1465,420],{"class":292},[220,1467,1468],{"class":296}," writer:\n",[220,1470,1471,1474,1477,1479,1482,1484,1487,1489,1492,1494,1497,1499,1501],{"class":101,"line":358},[220,1472,1473],{"class":296},"    rates.to_excel(writer, ",[220,1475,1476],{"class":319},"sheet_name",[220,1478,313],{"class":292},[220,1480,1481],{"class":229},"\"Rates\"",[220,1483,401],{"class":296},[220,1485,1486],{"class":319},"index",[220,1488,313],{"class":292},[220,1490,1491],{"class":345},"False",[220,1493,401],{"class":296},[220,1495,1496],{"class":319},"startrow",[220,1498,313],{"class":292},[220,1500,572],{"class":345},[220,1502,349],{"class":296},[220,1504,1505,1508,1510,1513,1515],{"class":101,"line":375},[220,1506,1507],{"class":296},"    ws ",[220,1509,313],{"class":292},[220,1511,1512],{"class":296}," writer.sheets[",[220,1514,1481],{"class":229},[220,1516,627],{"class":296},[220,1518,1519,1522,1525,1527,1529],{"class":101,"line":386},[220,1520,1521],{"class":296},"    ws[",[220,1523,1524],{"class":229},"\"A1\"",[220,1526,1321],{"class":296},[220,1528,313],{"class":292},[220,1530,1531],{"class":229}," \"Exchange rates\"\n",[220,1533,1534,1536,1539,1541,1543,1546,1549,1552,1555,1558,1561,1563,1566,1569,1572,1575,1578,1580,1583,1585,1588,1590,1593,1595],{"class":101,"line":426},[220,1535,1521],{"class":296},[220,1537,1538],{"class":229},"\"A2\"",[220,1540,1321],{"class":296},[220,1542,313],{"class":292},[220,1544,1545],{"class":292}," f",[220,1547,1548],{"class":229},"\"Source: ",[220,1550,325],{"class":1551},"sSjpA",[220,1553,1554],{"class":296},"url",[220,1556,1557],{"class":1551},"}",[220,1559,1560],{"class":229}," — fetched ",[220,1562,325],{"class":1551},[220,1564,1565],{"class":296},"fetched:",[220,1567,1568],{"class":292},"%",[220,1570,1571],{"class":296},"Y",[220,1573,1574],{"class":292},"-%",[220,1576,1577],{"class":296},"m",[220,1579,1574],{"class":292},[220,1581,1582],{"class":296},"d ",[220,1584,1568],{"class":292},[220,1586,1587],{"class":296},"H:",[220,1589,1568],{"class":292},[220,1591,1592],{"class":296},"M",[220,1594,1557],{"class":1551},[220,1596,1597],{"class":229},"\"\n",[220,1599,1600,1603,1606,1608,1611,1613,1616,1619,1622,1624,1626,1628,1630,1632,1635],{"class":101,"line":529},[220,1601,1602],{"class":292},"    for",[220,1604,1605],{"class":296}," col, width ",[220,1607,549],{"class":292},[220,1609,1610],{"class":345}," zip",[220,1612,395],{"class":296},[220,1614,1615],{"class":229},"\"ABCD\"",[220,1617,1618],{"class":296},", (",[220,1620,1621],{"class":345},"18",[220,1623,401],{"class":296},[220,1625,1621],{"class":345},[220,1627,401],{"class":296},[220,1629,1621],{"class":345},[220,1631,401],{"class":296},[220,1633,1634],{"class":345},"36",[220,1636,1637],{"class":296},")):\n",[220,1639,1640,1643,1645],{"class":101,"line":540},[220,1641,1642],{"class":296},"        ws.column_dimensions[col].width ",[220,1644,313],{"class":292},[220,1646,1647],{"class":296}," width\n",[220,1649,1650],{"class":101,"line":558},[220,1651,304],{"emptyLinePlaceholder":303},[220,1653,1655,1657,1660,1662,1664,1667,1669,1671,1674,1676,1678,1680],{"class":101,"line":1654},12,[220,1656,655],{"class":345},[220,1658,1659],{"class":296},"(pd.read_excel(",[220,1661,1451],{"class":229},[220,1663,401],{"class":296},[220,1665,1666],{"class":319},"skiprows",[220,1668,313],{"class":292},[220,1670,572],{"class":345},[220,1672,1673],{"class":296},").to_string(",[220,1675,1486],{"class":319},[220,1677,313],{"class":292},[220,1679,1491],{"class":345},[220,1681,1682],{"class":296},"))\n",[10,1684,1685,1686,1689,1690,1694],{},"In a real run, use ",[14,1687,1688],{},"datetime.now()"," for the fetch time. For a table you collect every day, append each fetch to a history sheet with a date column rather than overwriting — ",[23,1691,1693],{"href":1692},"\u002Fgetting-started-with-python-excel-automation\u002Fwriting-dataframes-to-excel-with-pandas\u002Fappend-a-dataframe-to-an-existing-excel-file-with-pandas\u002F","Append a DataFrame to an Existing Excel File with pandas"," shows how.",[206,1696,1698],{"id":1697},"when-read_html-is-not-enough","When read_html is not enough",[10,1700,1701,1703,1704,1707,1708,1710,1711,1713,1714,1717],{},[14,1702,126],{}," sees only the HTML the server sends. Tables built in the browser by JavaScript are absent from ",[14,1705,1706],{},"response.text"," — ",[14,1709,126],{}," raises \"No tables found\". Look in your browser's network tab for the JSON request that fills the table and call that instead (it is usually cleaner than the HTML); only if there is none, render the page with a browser automation tool such as Playwright and pass its HTML to ",[14,1712,126],{},". Tables behind a login need the session cookies or token the site issues; use a ",[14,1715,1716],{},"requests.Session"," that logs in first.",[206,1719,1721],{"id":1720},"common-pitfalls","Common pitfalls",[1723,1724,1725,1737,1748,1754,1763,1769],"ul",{},[1726,1727,1728,1732,1733,1736],"li",{},[1729,1730,1731],"strong",{},"Passing HTML as a plain string."," pandas 3 reads it as a path; wrap it in ",[14,1734,1735],{},"StringIO",".",[1726,1738,1739,1742,1743,1035,1745,1747],{},[1729,1740,1741],{},"Selecting by position."," Use ",[14,1744,585],{},[14,1746,589],{}," so layout changes do not switch tables.",[1726,1749,1750,1753],{},[1729,1751,1752],{},"Garbled characters."," Set the response or file encoding to UTF-8 before parsing.",[1726,1755,1756,1759,1760,1736],{},[1729,1757,1758],{},"Numbers left as text."," Footnotes, dashes and units block conversion; strip and ",[14,1761,1762],{},"to_numeric",[1726,1764,1765,1768],{},[1729,1766,1767],{},"Losing provenance."," Record the source URL and fetch time in the workbook.",[1726,1770,1771,1774],{},[1729,1772,1773],{},"Fetching too often."," Cache the HTML and respect the site's terms.",[206,1776,1778],{"id":1777},"performance-notes","Performance notes",[10,1780,1781,1782,1035,1784,1786],{},"Parsing is fast; the network is the slow part. Save the fetched HTML and parse from disk while developing, and use ",[14,1783,589],{},[14,1785,585],{}," so pandas builds DataFrames only for the tables you need.",[206,1788,1790],{"id":1789},"conclusion","Conclusion",[10,1792,1793,1794,1796,1797,1800,1801,1035,1803,1805,1806,1809,1810,1812],{},"Fetch the page with ",[14,1795,275],{},", check the status and encoding, and parse it with ",[14,1798,1799],{},"pd.read_html(StringIO(html))",", choosing the table by ",[14,1802,585],{},[14,1804,589],{}," rather than position. Flatten multi-row headers, strip footnote markers and dashes before ",[14,1807,1808],{},"pd.to_numeric",", and keep links with ",[14,1811,1285],{},". Save the table with its source URL and fetch time, and switch to the site's JSON endpoint when the table is built by JavaScript.",[206,1814,1816],{"id":1815},"frequently-asked-questions","Frequently asked questions",[10,1818,1819,1822],{},[1729,1820,1821],{},"How do I get a table from a web page into Excel with Python?","\nFetch the page with requests, pass its HTML to pandas.read_html wrapped in io.StringIO, pick the table you need from the list it returns, clean the columns, and save it with DataFrame.to_excel.",[10,1824,1825,1828],{},[1729,1826,1827],{},"Why does read_html say \"No such file or directory\" when I pass it HTML?","\npandas 3 treats a plain string as a file path or URL. Wrap HTML text in io.StringIO — pd.read_html(StringIO(html)) — and it is parsed as markup.",[10,1830,1831,1834],{},[1729,1832,1833],{},"read_html returns several tables. How do I get the right one?","\nPass match=\"some text\" to keep only tables containing that text, or attrs={\"id\": \"rates\"} to match the table's HTML attributes. An id is the most stable choice, because the table's position in the list changes when the site adds a banner or menu table.",[10,1836,1837,1840,1841,1843],{},[1729,1838,1839],{},"Why is a numeric column still text after read_html?","\nOne or more cells contain something that is not a number — a footnote marker such as ",[220,1842,23],{},", a dash for \"no value\", or a unit. Strip those with a regular expression and convert with pd.to_numeric(errors=\"coerce\").",[206,1845,1847],{"id":1846},"related","Related",[1723,1849,1850,1856,1861,1868],{},[1726,1851,1852,1853,1855],{},"Up one level: ",[23,1854,26],{"href":25}," — getting data in and out.",[1726,1857,1858,1860],{},[23,1859,38],{"href":37}," — when the site offers JSON.",[1726,1862,1863,1867],{},[23,1864,1866],{"href":1865},"\u002Fgetting-started-with-python-excel-automation\u002Fhandling-excel-file-formats-and-conversions\u002Fconvert-xml-to-excel-with-pandas\u002F","Convert XML to Excel with pandas"," — another markup format.",[1726,1869,1870,1874],{},[23,1871,1873],{"href":1872},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fconvert-currency-strings-to-numbers-with-pandas\u002F","Convert Currency Strings to Numbers with pandas"," — more number cleaning.",[1876,1877,1878],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}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 .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":216,"searchDepth":300,"depth":300,"links":1880},[1881,1882,1883,1884,1885,1886,1887,1888,1889,1890,1891,1892,1893],{"id":208,"depth":300,"text":209},{"id":266,"depth":300,"text":267},{"id":441,"depth":300,"text":442},{"id":775,"depth":300,"text":776},{"id":873,"depth":300,"text":874},{"id":1226,"depth":300,"text":1227},{"id":1383,"depth":300,"text":1384},{"id":1697,"depth":300,"text":1698},{"id":1720,"depth":300,"text":1721},{"id":1777,"depth":300,"text":1778},{"id":1789,"depth":300,"text":1790},{"id":1815,"depth":300,"text":1816},{"id":1846,"depth":300,"text":1847},"2026-09-25","Turn an HTML table on a web page into a clean Excel sheet. Fetch the page with requests, pick the right table out of several with read_html, flatten two-row headers, strip footnote markers and dashes so numbers become numbers, keep the links, and save the result with its source and time.","md",[1898,1900,1902,1904],{"q":1821,"a":1899},"Fetch the page with requests, pass its HTML to pandas.read_html wrapped in io.StringIO, pick the table you need from the list it returns, clean the columns, and save it with DataFrame.to_excel.",{"q":1827,"a":1901},"pandas 3 treats a plain string as a file path or URL. Wrap HTML text in io.StringIO — pd.read_html(StringIO(html)) — and it is parsed as markup.",{"q":1833,"a":1903},"Pass match=\"some text\" to keep only tables containing that text, or attrs={\"id\": \"rates\"} to match the table's HTML attributes. An id is the most stable choice, because the table's position in the list changes when the site adds a banner or menu table.",{"q":1839,"a":1905},"One or more cells contain something that is not a number — a footnote marker such as [a], a dash for \"no value\", or a unit. Strip those with a regular expression and convert with pd.to_numeric(errors=\"coerce\").","scrape a web page table into excel with pandas",null,{"breadcrumb":1909},[1910,1913,1916],{"name":1911,"item":1912},"Home","\u002F",{"name":1914,"item":1915},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":26,"item":25},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas",{"title":1919,"description":1920},"pandas read_html: Scrape a Web Table into Excel","Scrape an HTML table into Excel with pandas read_html and requests: StringIO for HTML text in pandas 3, choosing a table with match and attrs, encodings, multi-row headers, footnotes, thousands separators, extract_links and a source note.","scrape-a-web-page-table-into-excel-with-pandas","advanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fscrape-a-web-page-table-into-excel-with-pandas\u002Findex",[1924,1925],"data-sources","pandas","how-to","8_134EdP3nwF8jR1-005s3m3oLSH0C1FFwp9di3QPRQ",[1929,1933],{"title":1930,"path":1931,"stem":1932,"children":-1},"Refresh an Excel Report from a Database on a Schedule","\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule","advanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Frefresh-an-excel-report-from-a-database-on-a-schedule\u002Findex",{"title":1934,"path":1935,"stem":1936,"children":-1},"Sync Google Sheets with Excel Using Python","\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fsync-google-sheets-with-excel-using-python","advanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fsync-google-sheets-with-excel-using-python\u002Findex",[1938,1940],{"path":1939,"title":1914},"\u002Fadvanced-data-transformation-and-cleaning",{"path":1941,"title":26},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases",[],[1944,1947,1950,1953,1956,1959],{"path":1945,"title":1946,"kind":1926},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fread-an-excel-file-directly-from-amazon-s3-with-pandas","Read an Excel File Directly from Amazon S3 with pandas",{"path":1948,"title":1949,"kind":1926},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fcache-api-responses-when-building-excel-reports","Cache API Responses When Building Excel Reports",{"path":1951,"title":1952,"kind":1926},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fcompare-an-excel-file-with-a-database-table","Compare an Excel File with a Database Table",{"path":1954,"title":1955,"kind":1926},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fdownload-new-excel-files-from-an-sftp-server","Download New Excel Files from an SFTP Server",{"path":1957,"title":1958,"kind":1926},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fexport-a-large-database-table-to-excel-in-chunks","Export a Large Database Table to Excel in Chunks",{"path":1960,"title":1961,"kind":1926},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmoving-data-between-excel-and-databases\u002Fexport-database-lookup-tables-as-excel-dropdown-lists","Export Database Lookup Tables as Excel Dropdown Lists",1790972096936]