[{"data":1,"prerenderedAt":1891},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp":3,"surround:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp":1857,"ancestors:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp":1866,"children:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp":1871,"related:\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp":1872},{"id":4,"title":5,"body":6,"dateModified":1825,"datePublished":1825,"description":1826,"extension":1827,"faq":1828,"keyword":1837,"kind":1838,"meta":1839,"navigation":328,"path":1847,"seo":1848,"slug":1851,"stem":1852,"topics":1853,"type":1855,"__hash__":1856},"docs\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp\u002Findex.md","Upload an Excel Report over SFTP with Python",{"type":7,"value":8,"toc":1812},"minimark",[9,19,210,215,246,249,284,291,295,306,453,479,483,498,687,697,718,821,825,836,943,957,961,964,1280,1286,1290,1293,1341,1345,1352,1519,1537,1671,1675,1721,1725,1747,1751,1757,1763,1769,1775,1779,1808],[10,11,12,13,18],"p",{},"Plenty of organisations still exchange reports by dropping files on an SFTP server: a bank expects a reconciliation workbook every morning, a supplier collects a stock report, a regulator's portal accepts uploads over SSH. The protocol is old and simple, which makes it easy to get the dangerous parts wrong — trusting any server key, or letting the partner's import job read a file that is still being written. This guide, part of ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002F","Publishing Excel Reports to Cloud Storage",", uploads a report with paramiko the careful way. Each behaviour described was checked against a local SFTP server.",[20,21,29,30,29,34,29,38,29,46,29,56,29,67,29,74,29,79,29,86,29,91,29,94,29,100,29,104,29,110,29,112,29,114,29,119,29,124,29,134,29,140,29,145,29,148,29,153,29,158,29,161,29,164,29,169,29,174,29,177,29,180,29,187,29,192,29,197,29,200,29,205],"svg",{"viewBox":22,"role":23,"ariaLabelledBy":24,"xmlns":27,"style":28},"4 0 752 370","img",[25,26],"sf-seq-t","sf-seq-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  ",[31,32,33],"title",{"id":25},"A safe SFTP delivery",[35,36,37],"desc",{"id":26},"The script connects and checks the server's host key against a known_hosts file before sending anything. It authenticates with its private key, uploads the workbook under a .part name, confirms the size on the server, and renames the file to its final name. The partner's job polls for .xlsx files and only ever sees the complete file.",[39,40],"rect",{"x":41,"y":42,"width":43,"height":44,"fill":45},"4","0","752","370","#ffffff",[39,47],{"x":48,"y":49,"width":50,"height":51,"rx":52,"fill":53,"stroke":54,"style":55},"20.0","16.0","180.0","78.0","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[57,58,60],"g",{"transform":59},"translate(98.0,30.5) scale(1.000)",[61,62],"path",{"d":63,"fill":64,"stroke":65,"style":66},"M12 3c-4 0-4 1.6-4 3v2h4.2v1H6c-2 0-3 1.4-3 4s1 4 3 4h2v-2.6c0-1.6 1.3-2.9 2.9-2.9h4.2c1.3 0 2.4-1.1 2.4-2.4V6c0-1.6-1.8-3-5.5-3z M10 5.5h.01 M12 21c4 0 4-1.6 4-3v-2h-4.2v-1H18c2 0 3-1.4 3-4s-1-4-3-4h-2v2.6c0 1.6-1.3 2.9-2.9 2.9H8.9c-1.3 0-2.4 1.1-2.4 2.4V18c0 1.6 1.8 3 5.5 3z M14 18.5h.01","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[68,69,73],"text",{"x":70,"y":71,"style":72},"110.0","76.5","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Report script",[39,75],{"x":76,"y":49,"width":50,"height":51,"rx":52,"fill":77,"stroke":78,"style":55},"290.0","#e7ebef","var(--line,#cdd5e6)",[57,80,82],{"transform":81},"translate(368.0,30.5) scale(1.000)",[61,83],{"d":84,"fill":64,"stroke":85,"style":66},"M4 4h16v6H4z M4 14h16v6H4z M8 7h.01 M8 17h.01","var(--text,#172033)",[68,87,90],{"x":88,"y":71,"style":89},"380.0","font-size:13px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","SFTP server",[39,92],{"x":93,"y":49,"width":50,"height":51,"rx":52,"fill":77,"stroke":78,"style":55},"560.0",[57,95,97],{"transform":96},"translate(638.0,30.5) scale(1.000)",[61,98],{"d":99,"fill":64,"stroke":85,"style":66},"M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6 M16 4.5a3.3 3.3 0 0 1 0 6.3 M18 14.3c2.2.7 3.5 2.8 3.5 5.7",[68,101,103],{"x":102,"y":71,"style":89},"650.0","Partner job",[105,106],"line",{"x1":70,"y1":107,"x2":70,"y2":108,"stroke":78,"style":109},"94.0","354.0","stroke-width:1.5px;stroke-dasharray:5 4",[105,111],{"x1":88,"y1":107,"x2":88,"y2":108,"stroke":78,"style":109},[105,113],{"x1":102,"y1":107,"x2":102,"y2":108,"stroke":78,"style":109},[105,115],{"x1":116,"y1":117,"x2":118,"y2":117,"stroke":54,"style":55},"375.0","134.0","119.0",[120,121],"polygon",{"points":122,"fill":123},"115.0,134.0 124.0,129.0 124.0,139.0","#5b5cf0",[39,125],{"x":126,"y":127,"width":128,"height":129,"rx":130,"fill":131,"stroke":132,"style":133},"204.7","105.0","80.5","21.0","10","#fdefd8","var(--gold,#b4740a)","stroke-width:1.2px",[68,135,139],{"x":136,"y":137,"style":138},"245.0","119.5","font-size:11.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","1. host key",[105,141],{"x1":142,"y1":143,"x2":144,"y2":143,"stroke":54,"style":55},"115.0","182.0","371.0",[120,146],{"points":147,"fill":123},"375.0,182.0 366.0,187.0 366.0,177.0",[39,149],{"x":150,"y":151,"width":152,"height":129,"rx":130,"fill":53,"stroke":54,"style":133},"80.0","153.0","330.0",[68,154,157],{"x":136,"y":155,"style":156},"167.5","font-size:11.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","2. key matches known_hosts → auth with private key",[105,159],{"x1":142,"y1":160,"x2":144,"y2":160,"stroke":54,"style":55},"230.0",[120,162],{"points":163,"fill":123},"375.0,230.0 366.0,235.0 366.0,225.0",[39,165],{"x":166,"y":167,"width":168,"height":129,"rx":130,"fill":45,"stroke":78,"style":133},"144.6","201.0","200.7",[68,170,173],{"x":136,"y":171,"style":172},"215.5","font-size:11.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","3. put sales_2026-09.xlsx.part",[105,175],{"x1":142,"y1":176,"x2":144,"y2":176,"stroke":54,"style":55},"278.0",[120,178],{"points":179,"fill":123},"375.0,278.0 366.0,283.0 366.0,273.0",[39,181],{"x":182,"y":183,"width":184,"height":129,"rx":130,"fill":185,"stroke":186,"style":133},"121.3","249.0","247.4","#d9f4f1","var(--teal,#0f9488)",[68,188,191],{"x":136,"y":189,"style":190},"263.5","font-size:11.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","4. stat size, then posix_rename to .xlsx",[105,193],{"x1":194,"y1":195,"x2":196,"y2":195,"stroke":54,"style":55},"645.0","326.0","389.0",[120,198],{"points":199,"fill":123},"385.0,326.0 394.0,321.0 394.0,331.0",[39,201],{"x":202,"y":203,"width":204,"height":129,"rx":130,"fill":185,"stroke":186,"style":133},"422.2","297.0","185.5",[68,206,209],{"x":207,"y":208,"style":190},"515.0","311.5","5. poll *.xlsx → complete file",[211,212,214],"h2",{"id":213},"prerequisites","Prerequisites",[216,217,222],"pre",{"className":218,"code":219,"language":220,"meta":221,"style":221},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install paramiko pandas openpyxl\n","bash","",[223,224,225],"code",{"__ignoreMap":221},[226,227,229,233,237,240,243],"span",{"class":105,"line":228},1,[226,230,232],{"class":231},"sMTad","pip",[226,234,236],{"class":235},"srMev"," install",[226,238,239],{"class":235}," paramiko",[226,241,242],{"class":235}," pandas",[226,244,245],{"class":235}," openpyxl\n",[10,247,248],{},"You need the server's host name, a user account and the server's public host key, which the partner should send you through a separate channel. Generate a key pair for the script and give the partner the public half:",[216,250,252],{"className":218,"code":251,"language":220,"meta":221,"style":221},"ssh-keygen -t ed25519 -f reports_sftp_key -N \"\" -C \"report-uploads\"\n",[223,253,254],{"__ignoreMap":221},[226,255,256,259,263,266,269,272,275,278,281],{"class":105,"line":228},[226,257,258],{"class":231},"ssh-keygen",[226,260,262],{"class":261},"sP0c6"," -t",[226,264,265],{"class":235}," ed25519",[226,267,268],{"class":261}," -f",[226,270,271],{"class":235}," reports_sftp_key",[226,273,274],{"class":261}," -N",[226,276,277],{"class":235}," \"\"",[226,279,280],{"class":261}," -C",[226,282,283],{"class":235}," \"report-uploads\"\n",[10,285,286,287,290],{},"An empty passphrase lets the job run unattended; protect the private key file with permissions instead (",[223,288,289],{},"chmod 600",").",[211,292,294],{"id":293},"step-1-connect-and-check-who-you-are-talking-to","Step 1 — Connect, and check who you are talking to",[10,296,297,298,301,302,305],{},"Many examples start with ",[223,299,300],{},"set_missing_host_key_policy(paramiko.AutoAddPolicy())",", which teaches a script to accept any server's key — including an impostor's. For an unattended job that sends financial data, pin the key. Put the partner's host key in a ",[223,303,304],{},"known_hosts"," file you control and keep paramiko's default policy, which refuses unknown servers:",[216,307,311],{"className":308,"code":309,"language":310,"meta":221,"style":221},"language-python shiki shiki-themes github-light github-dark-high-contrast","import paramiko\n\ndef connect(host, port, user, key_file, known_hosts=\"known_hosts\"):\n    client = paramiko.SSHClient()\n    client.load_host_keys(known_hosts)\n    client.set_missing_host_key_policy(paramiko.RejectPolicy())     # the default, made explicit\n    client.connect(host, port=port, username=user, key_filename=key_file,\n                   look_for_keys=False, allow_agent=False, timeout=30)\n    return client\n","python",[223,312,313,323,330,352,363,369,379,410,444],{"__ignoreMap":221},[226,314,315,319],{"class":105,"line":228},[226,316,318],{"class":317},"s-kum","import",[226,320,322],{"class":321},"skGVy"," paramiko\n",[226,324,326],{"class":105,"line":325},2,[226,327,329],{"emptyLinePlaceholder":328},true,"\n",[226,331,333,336,340,343,346,349],{"class":105,"line":332},3,[226,334,335],{"class":317},"def",[226,337,339],{"class":338},"s_Opv"," connect",[226,341,342],{"class":321},"(host, port, user, key_file, known_hosts",[226,344,345],{"class":317},"=",[226,347,348],{"class":235},"\"known_hosts\"",[226,350,351],{"class":321},"):\n",[226,353,355,358,360],{"class":105,"line":354},4,[226,356,357],{"class":321},"    client ",[226,359,345],{"class":317},[226,361,362],{"class":321}," paramiko.SSHClient()\n",[226,364,366],{"class":105,"line":365},5,[226,367,368],{"class":321},"    client.load_host_keys(known_hosts)\n",[226,370,372,375],{"class":105,"line":371},6,[226,373,374],{"class":321},"    client.set_missing_host_key_policy(paramiko.RejectPolicy())     ",[226,376,378],{"class":377},"s-wDw","# the default, made explicit\n",[226,380,382,385,389,391,394,397,399,402,405,407],{"class":105,"line":381},7,[226,383,384],{"class":321},"    client.connect(host, ",[226,386,388],{"class":387},"sa561","port",[226,390,345],{"class":317},[226,392,393],{"class":321},"port, ",[226,395,396],{"class":387},"username",[226,398,345],{"class":317},[226,400,401],{"class":321},"user, ",[226,403,404],{"class":387},"key_filename",[226,406,345],{"class":317},[226,408,409],{"class":321},"key_file,\n",[226,411,413,416,418,421,424,427,429,431,433,436,438,441],{"class":105,"line":412},8,[226,414,415],{"class":387},"                   look_for_keys",[226,417,345],{"class":317},[226,419,420],{"class":261},"False",[226,422,423],{"class":321},", ",[226,425,426],{"class":387},"allow_agent",[226,428,345],{"class":317},[226,430,420],{"class":261},[226,432,423],{"class":321},[226,434,435],{"class":387},"timeout",[226,437,345],{"class":317},[226,439,440],{"class":261},"30",[226,442,443],{"class":321},")\n",[226,445,447,450],{"class":105,"line":446},9,[226,448,449],{"class":317},"    return",[226,451,452],{"class":321}," client\n",[10,454,455,456,458,459,462,463,466,467,470,471,474,475,478],{},"A ",[223,457,304],{}," line for a server on a non-standard port looks like ",[223,460,461],{},"[sftp.partner.example]:2222 ssh-ed25519 AAAAC3Nz…",". With no matching entry, the connection fails with ",[223,464,465],{},"SSHException: Server '[host]:port' not found in known_hosts","; if the server presents a different key, it fails with ",[223,468,469],{},"BadHostKeyException: Host key for server … does not match",". Both are exactly what you want: an alert, not an upload. ",[223,472,473],{},"look_for_keys=False"," and ",[223,476,477],{},"allow_agent=False"," make the script use only the key you gave it, so it behaves the same on every machine.",[211,480,482],{"id":481},"step-2-upload-under-a-temporary-name-then-rename","Step 2 — Upload under a temporary name, then rename",[10,484,485,486,489,490,493,494,497],{},"Partners usually poll a folder and import every ",[223,487,488],{},".xlsx"," that appears. If the file appears under its final name while it is still being written, they import half a workbook — a ",[223,491,492],{},"BadZipFile"," on their side, or worse, a truncated one that happens to open. Upload under a ",[223,495,496],{},".part"," name, confirm the size, then rename:",[216,499,501],{"className":308,"code":500,"language":310,"meta":221,"style":221},"import io\n\ndef deliver(sftp, data, remote_dir, filename):\n    final = f\"{remote_dir}\u002F{filename}\"\n    partial = final + \".part\"\n    attrs = sftp.putfo(io.BytesIO(data), partial, file_size=len(data), confirm=True)\n    if attrs.st_size != len(data):\n        raise IOError(f\"{partial}: {attrs.st_size} bytes on server, {len(data)} sent\")\n    sftp.posix_rename(partial, final)\n    return final\n",[223,502,503,510,514,524,560,576,607,624,674,679],{"__ignoreMap":221},[226,504,505,507],{"class":105,"line":228},[226,506,318],{"class":317},[226,508,509],{"class":321}," io\n",[226,511,512],{"class":105,"line":325},[226,513,329],{"emptyLinePlaceholder":328},[226,515,516,518,521],{"class":105,"line":332},[226,517,335],{"class":317},[226,519,520],{"class":338}," deliver",[226,522,523],{"class":321},"(sftp, data, remote_dir, filename):\n",[226,525,526,529,531,534,537,541,544,547,550,552,555,557],{"class":105,"line":354},[226,527,528],{"class":321},"    final ",[226,530,345],{"class":317},[226,532,533],{"class":317}," f",[226,535,536],{"class":235},"\"",[226,538,540],{"class":539},"sSjpA","{",[226,542,543],{"class":321},"remote_dir",[226,545,546],{"class":539},"}",[226,548,549],{"class":235},"\u002F",[226,551,540],{"class":539},[226,553,554],{"class":321},"filename",[226,556,546],{"class":539},[226,558,559],{"class":235},"\"\n",[226,561,562,565,567,570,573],{"class":105,"line":365},[226,563,564],{"class":321},"    partial ",[226,566,345],{"class":317},[226,568,569],{"class":321}," final ",[226,571,572],{"class":317},"+",[226,574,575],{"class":235}," \".part\"\n",[226,577,578,581,583,586,589,591,594,597,600,602,605],{"class":105,"line":371},[226,579,580],{"class":321},"    attrs ",[226,582,345],{"class":317},[226,584,585],{"class":321}," sftp.putfo(io.BytesIO(data), partial, ",[226,587,588],{"class":387},"file_size",[226,590,345],{"class":317},[226,592,593],{"class":261},"len",[226,595,596],{"class":321},"(data), ",[226,598,599],{"class":387},"confirm",[226,601,345],{"class":317},[226,603,604],{"class":261},"True",[226,606,443],{"class":321},[226,608,609,612,615,618,621],{"class":105,"line":381},[226,610,611],{"class":317},"    if",[226,613,614],{"class":321}," attrs.st_size ",[226,616,617],{"class":317},"!=",[226,619,620],{"class":261}," len",[226,622,623],{"class":321},"(data):\n",[226,625,626,629,632,635,638,640,642,645,647,650,652,655,657,660,662,664,667,669,672],{"class":105,"line":412},[226,627,628],{"class":317},"        raise",[226,630,631],{"class":261}," IOError",[226,633,634],{"class":321},"(",[226,636,637],{"class":317},"f",[226,639,536],{"class":235},[226,641,540],{"class":539},[226,643,644],{"class":321},"partial",[226,646,546],{"class":539},[226,648,649],{"class":235},": ",[226,651,540],{"class":539},[226,653,654],{"class":321},"attrs.st_size",[226,656,546],{"class":539},[226,658,659],{"class":235}," bytes on server, ",[226,661,540],{"class":539},[226,663,593],{"class":261},[226,665,666],{"class":321},"(data)",[226,668,546],{"class":539},[226,670,671],{"class":235}," sent\"",[226,673,443],{"class":321},[226,675,676],{"class":105,"line":446},[226,677,678],{"class":321},"    sftp.posix_rename(partial, final)\n",[226,680,682,684],{"class":105,"line":681},10,[226,683,449],{"class":317},[226,685,686],{"class":321}," final\n",[10,688,689,692,693,696],{},[223,690,691],{},"putfo"," uploads from a file object, so a report built in memory never touches the local disk. With ",[223,694,695],{},"confirm=True"," paramiko checks the size on the server after the upload and raises if it differs; the explicit check above makes the rule visible.",[10,698,699,700,703,704,707,708,711,712,714,715,717],{},"Use ",[223,701,702],{},"posix_rename"," rather than ",[223,705,706],{},"rename",". SFTP's standard rename refuses to replace an existing file — against the test server, delivering the same report twice failed with ",[223,709,710],{},"OSError: File already exists"," — while ",[223,713,702],{},", an OpenSSH extension most servers support, replaces it in one step. If the partner's rules say a delivered file must never be replaced, use ",[223,716,706],{}," precisely because it refuses.",[20,719,29,724,29,727,29,730,29,733,29,738,29,746,29,751,29,757,29,763,29,770,29,774,29,779,29,783,29,785,29,792,29,797,29,801,29,805,29,810,29,813,29,818],{"viewBox":720,"role":23,"ariaLabelledBy":721,"xmlns":27,"style":28},"4 0 752 207",[722,723],"sf-rename-t","sf-rename-d",[31,725,726],{"id":722},"rename or posix_rename for the final step",[35,728,729],{"id":723},"sftp.rename follows the original SFTP rules and fails with File already exists when the target exists, which protects a delivered file from being replaced. sftp.posix_rename uses an OpenSSH extension and replaces the target in one step, which suits reports that are meant to be redelivered, but needs a server that supports the extension.",[39,731],{"x":41,"y":42,"width":43,"height":732,"fill":45},"207",[39,734],{"x":48,"y":49,"width":735,"height":736,"rx":737,"fill":131,"stroke":132,"style":55},"351.0","175.0","14",[57,739,741],{"transform":740},"translate(182.5,29.0) scale(1.083)",[61,742],{"d":743,"fill":64,"stroke":744,"style":745},"M6 11h12v9H6z M8.5 11V8a3.5 3.5 0 0 1 7 0v3","var(--gold-ink,#7a4e06)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[68,747,750],{"x":748,"y":51,"style":749},"195.5","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","sftp.rename",[105,752],{"x1":753,"y1":754,"x2":755,"y2":754,"stroke":132,"style":756},"36.0","91.0","355.0","stroke-width:1px",[68,758,762],{"x":759,"y":760,"style":761},"60.0","111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","never replaces a delivered file",[57,764,766],{"transform":765},"translate(38.5,99.5) scale(0.625)",[61,767],{"d":768,"fill":64,"stroke":186,"style":769},"M5 12.5l4.5 4.5L19 7","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[68,771,773],{"x":759,"y":772,"style":761},"137.0","every SFTP server",[57,775,777],{"transform":776},"translate(38.5,125.5) scale(0.625)",[61,778],{"d":768,"fill":64,"stroke":186,"style":769},[68,780,782],{"x":759,"y":781,"style":761},"163.0","− rerun fails: File already exists",[39,784],{"x":196,"y":49,"width":735,"height":736,"rx":737,"fill":185,"stroke":186,"style":55},[57,786,788],{"transform":787},"translate(551.5,29.0) scale(1.083)",[61,789],{"d":790,"fill":64,"stroke":791,"style":745},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--teal-ink,#0b6157)",[68,793,796],{"x":794,"y":51,"style":795},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","sftp.posix_rename",[105,798],{"x1":799,"y1":754,"x2":800,"y2":754,"stroke":186,"style":756},"405.0","724.0",[68,802,804],{"x":803,"y":760,"style":761},"429.0","replaces in one step",[57,806,808],{"transform":807},"translate(407.5,99.5) scale(0.625)",[61,809],{"d":768,"fill":64,"stroke":186,"style":769},[68,811,812],{"x":803,"y":772,"style":761},"reruns just work",[57,814,816],{"transform":815},"translate(407.5,125.5) scale(0.625)",[61,817],{"d":768,"fill":64,"stroke":186,"style":769},[68,819,820],{"x":803,"y":781,"style":761},"− needs the OpenSSH extension",[211,822,824],{"id":823},"step-3-create-dated-folders-on-the-server","Step 3 — Create dated folders on the server",[10,826,827,828,831,832,835],{},"Many drop zones expect ",[223,829,830],{},"inbound\u002F2026\u002F09\u002F",". SFTP has no ",[223,833,834],{},"mkdir -p","; create each level that does not exist:",[216,837,839],{"className":308,"code":838,"language":310,"meta":221,"style":221},"def makedirs(sftp, path):\n    current = \"\"\n    for part in [p for p in path.split(\"\u002F\") if p]:\n        current += \"\u002F\" + part\n        try:\n            sftp.stat(current)\n        except FileNotFoundError:\n            sftp.mkdir(current)\n",[223,840,841,851,861,898,915,923,928,938],{"__ignoreMap":221},[226,842,843,845,848],{"class":105,"line":228},[226,844,335],{"class":317},[226,846,847],{"class":338}," makedirs",[226,849,850],{"class":321},"(sftp, path):\n",[226,852,853,856,858],{"class":105,"line":325},[226,854,855],{"class":321},"    current ",[226,857,345],{"class":317},[226,859,860],{"class":235}," \"\"\n",[226,862,863,866,869,872,875,878,881,883,886,889,892,895],{"class":105,"line":332},[226,864,865],{"class":317},"    for",[226,867,868],{"class":321}," part ",[226,870,871],{"class":317},"in",[226,873,874],{"class":321}," [p ",[226,876,877],{"class":317},"for",[226,879,880],{"class":321}," p ",[226,882,871],{"class":317},[226,884,885],{"class":321}," path.split(",[226,887,888],{"class":235},"\"\u002F\"",[226,890,891],{"class":321},") ",[226,893,894],{"class":317},"if",[226,896,897],{"class":321}," p]:\n",[226,899,900,903,906,909,912],{"class":105,"line":354},[226,901,902],{"class":321},"        current ",[226,904,905],{"class":317},"+=",[226,907,908],{"class":235}," \"\u002F\"",[226,910,911],{"class":317}," +",[226,913,914],{"class":321}," part\n",[226,916,917,920],{"class":105,"line":365},[226,918,919],{"class":317},"        try",[226,921,922],{"class":321},":\n",[226,924,925],{"class":105,"line":371},[226,926,927],{"class":321},"            sftp.stat(current)\n",[226,929,930,933,936],{"class":105,"line":381},[226,931,932],{"class":317},"        except",[226,934,935],{"class":261}," FileNotFoundError",[226,937,922],{"class":321},[226,939,940],{"class":105,"line":412},[226,941,942],{"class":321},"            sftp.mkdir(current)\n",[10,944,945,948,949,952,953,956],{},[223,946,947],{},"stat"," on a missing path raises ",[223,950,951],{},"FileNotFoundError",", which makes the check readable. Some servers forbid creating directories; if the partner pre-creates them, skip this step rather than failing on ",[223,954,955],{},"PermissionError",".",[211,958,960],{"id":959},"step-4-put-it-together-with-a-read-back-check","Step 4 — Put it together, with a read-back check",[10,962,963],{},"The complete delivery connects, creates the folder, uploads, renames, and reads the file back to compare its hash — a cheap check for a report of a few megabytes:",[216,965,967],{"className":308,"code":966,"language":310,"meta":221,"style":221},"import hashlib\nimport pandas as pd\n\ndef upload_report(data, period, host=\"sftp.partner.example\", port=22,\n                  user=\"acme-reports\", key_file=\"reports_sftp_key\"):\n    remote_dir = f\"\u002Finbound\u002F{period[:4]}\u002F{period[5:]}\"\n    with connect(host, port, user, key_file) as client, client.open_sftp() as sftp:\n        makedirs(sftp, remote_dir)\n        final = deliver(sftp, data, remote_dir, f\"stock_{period}.xlsx\")\n        with sftp.open(final, \"rb\") as remote:\n            remote.prefetch()\n            if hashlib.sha256(remote.read()).digest() != hashlib.sha256(data).digest():\n                raise IOError(f\"{final}: content differs after upload\")\n    return final\n\nbuffer = io.BytesIO()\npd.DataFrame({\"sku\": [\"A-100\", \"B-220\"], \"on_hand\": [42, 7]}).to_excel(buffer, index=False)\nprint(upload_report(buffer.getvalue(), \"2026-09\"))\n",[223,968,969,976,989,993,1019,1039,1080,1098,1103,1130,1148,1154,1168,1194,1201,1206,1217,1265],{"__ignoreMap":221},[226,970,971,973],{"class":105,"line":228},[226,972,318],{"class":317},[226,974,975],{"class":321}," hashlib\n",[226,977,978,980,983,986],{"class":105,"line":325},[226,979,318],{"class":317},[226,981,982],{"class":321}," pandas ",[226,984,985],{"class":317},"as",[226,987,988],{"class":321}," pd\n",[226,990,991],{"class":105,"line":332},[226,992,329],{"emptyLinePlaceholder":328},[226,994,995,997,1000,1003,1005,1008,1011,1013,1016],{"class":105,"line":354},[226,996,335],{"class":317},[226,998,999],{"class":338}," upload_report",[226,1001,1002],{"class":321},"(data, period, host",[226,1004,345],{"class":317},[226,1006,1007],{"class":235},"\"sftp.partner.example\"",[226,1009,1010],{"class":321},", port",[226,1012,345],{"class":317},[226,1014,1015],{"class":261},"22",[226,1017,1018],{"class":321},",\n",[226,1020,1021,1024,1026,1029,1032,1034,1037],{"class":105,"line":365},[226,1022,1023],{"class":321},"                  user",[226,1025,345],{"class":317},[226,1027,1028],{"class":235},"\"acme-reports\"",[226,1030,1031],{"class":321},", key_file",[226,1033,345],{"class":317},[226,1035,1036],{"class":235},"\"reports_sftp_key\"",[226,1038,351],{"class":321},[226,1040,1041,1044,1046,1048,1051,1053,1056,1058,1061,1063,1065,1067,1070,1073,1076,1078],{"class":105,"line":371},[226,1042,1043],{"class":321},"    remote_dir ",[226,1045,345],{"class":317},[226,1047,533],{"class":317},[226,1049,1050],{"class":235},"\"\u002Finbound\u002F",[226,1052,540],{"class":539},[226,1054,1055],{"class":321},"period[:",[226,1057,41],{"class":261},[226,1059,1060],{"class":321},"]",[226,1062,546],{"class":539},[226,1064,549],{"class":235},[226,1066,540],{"class":539},[226,1068,1069],{"class":321},"period[",[226,1071,1072],{"class":261},"5",[226,1074,1075],{"class":321},":]",[226,1077,546],{"class":539},[226,1079,559],{"class":235},[226,1081,1082,1085,1088,1090,1093,1095],{"class":105,"line":381},[226,1083,1084],{"class":317},"    with",[226,1086,1087],{"class":321}," connect(host, port, user, key_file) ",[226,1089,985],{"class":317},[226,1091,1092],{"class":321}," client, client.open_sftp() ",[226,1094,985],{"class":317},[226,1096,1097],{"class":321}," sftp:\n",[226,1099,1100],{"class":105,"line":412},[226,1101,1102],{"class":321},"        makedirs(sftp, remote_dir)\n",[226,1104,1105,1108,1110,1113,1115,1118,1120,1123,1125,1128],{"class":105,"line":446},[226,1106,1107],{"class":321},"        final ",[226,1109,345],{"class":317},[226,1111,1112],{"class":321}," deliver(sftp, data, remote_dir, ",[226,1114,637],{"class":317},[226,1116,1117],{"class":235},"\"stock_",[226,1119,540],{"class":539},[226,1121,1122],{"class":321},"period",[226,1124,546],{"class":539},[226,1126,1127],{"class":235},".xlsx\"",[226,1129,443],{"class":321},[226,1131,1132,1135,1138,1141,1143,1145],{"class":105,"line":681},[226,1133,1134],{"class":317},"        with",[226,1136,1137],{"class":321}," sftp.open(final, ",[226,1139,1140],{"class":235},"\"rb\"",[226,1142,891],{"class":321},[226,1144,985],{"class":317},[226,1146,1147],{"class":321}," remote:\n",[226,1149,1151],{"class":105,"line":1150},11,[226,1152,1153],{"class":321},"            remote.prefetch()\n",[226,1155,1157,1160,1163,1165],{"class":105,"line":1156},12,[226,1158,1159],{"class":317},"            if",[226,1161,1162],{"class":321}," hashlib.sha256(remote.read()).digest() ",[226,1164,617],{"class":317},[226,1166,1167],{"class":321}," hashlib.sha256(data).digest():\n",[226,1169,1171,1174,1176,1178,1180,1182,1184,1187,1189,1192],{"class":105,"line":1170},13,[226,1172,1173],{"class":317},"                raise",[226,1175,631],{"class":261},[226,1177,634],{"class":321},[226,1179,637],{"class":317},[226,1181,536],{"class":235},[226,1183,540],{"class":539},[226,1185,1186],{"class":321},"final",[226,1188,546],{"class":539},[226,1190,1191],{"class":235},": content differs after upload\"",[226,1193,443],{"class":321},[226,1195,1197,1199],{"class":105,"line":1196},14,[226,1198,449],{"class":317},[226,1200,686],{"class":321},[226,1202,1204],{"class":105,"line":1203},15,[226,1205,329],{"emptyLinePlaceholder":328},[226,1207,1209,1212,1214],{"class":105,"line":1208},16,[226,1210,1211],{"class":321},"buffer ",[226,1213,345],{"class":317},[226,1215,1216],{"class":321}," io.BytesIO()\n",[226,1218,1220,1223,1226,1229,1232,1234,1237,1240,1243,1245,1248,1250,1253,1256,1259,1261,1263],{"class":105,"line":1219},17,[226,1221,1222],{"class":321},"pd.DataFrame({",[226,1224,1225],{"class":235},"\"sku\"",[226,1227,1228],{"class":321},": [",[226,1230,1231],{"class":235},"\"A-100\"",[226,1233,423],{"class":321},[226,1235,1236],{"class":235},"\"B-220\"",[226,1238,1239],{"class":321},"], ",[226,1241,1242],{"class":235},"\"on_hand\"",[226,1244,1228],{"class":321},[226,1246,1247],{"class":261},"42",[226,1249,423],{"class":321},[226,1251,1252],{"class":261},"7",[226,1254,1255],{"class":321},"]}).to_excel(buffer, ",[226,1257,1258],{"class":387},"index",[226,1260,345],{"class":317},[226,1262,420],{"class":261},[226,1264,443],{"class":321},[226,1266,1268,1271,1274,1277],{"class":105,"line":1267},18,[226,1269,1270],{"class":261},"print",[226,1272,1273],{"class":321},"(upload_report(buffer.getvalue(), ",[226,1275,1276],{"class":235},"\"2026-09\"",[226,1278,1279],{"class":321},"))\n",[10,1281,1282,1285],{},[223,1283,1284],{},"prefetch()"," speeds up reading a file back by requesting blocks in parallel; without it, paramiko reads over SFTP noticeably slowly. Many drop zones are write-only and refuse reads; there, rely on the size check from Step 2.",[211,1287,1289],{"id":1288},"step-5-follow-the-partners-conventions","Step 5 — Follow the partner's conventions",[10,1291,1292],{},"The upload is only half of an exchange. Agree these with the receiving side and encode them in the job:",[1294,1295,1296,1308,1321,1332],"ul",{},[1297,1298,1299,1303,1304,1307],"li",{},[1300,1301,1302],"strong",{},"File names."," Fixed patterns such as ",[223,1305,1306],{},"stock_YYYY-MM.xlsx"," let their import match files without guessing. Avoid spaces and non-ASCII characters.",[1297,1309,1310,1313,1314,1317,1318,956],{},[1300,1311,1312],{},"Done markers."," Some partners want an empty ",[223,1315,1316],{},"stock_2026-09.ok"," written after the workbook instead of a rename. Upload it last, with ",[223,1319,1320],{},"putfo(io.BytesIO(b\"\"), …)",[1297,1322,1323,1326,1327,1331],{},[1300,1324,1325],{},"Retries."," Transient network failures are worth two or three retries with a pause, as in ",[14,1328,1330],{"href":1329},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python",". Host key and authentication failures are not — alert on them straight away.",[1297,1333,1334,1337,1338,1340],{},[1300,1335,1336],{},"Clean-up."," A failed run can leave a ",[223,1339,496],{}," file behind. Delete leftovers at the start of the next run, or the partner will ask about them.",[211,1342,1344],{"id":1343},"step-6-test-against-a-local-sftp-server","Step 6 — Test against a local SFTP server",[10,1346,1347,1348,1351],{},"Waiting for the partner's test environment slows every change. ",[223,1349,1350],{},"asyncssh"," can run a real SFTP server in a few lines, rooted in a local folder, which is how the behaviour in this guide was checked:",[216,1353,1355],{"className":308,"code":1354,"language":310,"meta":221,"style":221},"import asyncio\nimport asyncssh\n\nclass DropZone(asyncssh.SFTPServer):\n    def __init__(self, channel):\n        super().__init__(channel, chroot=b\"test_remote\")\n\nasync def serve():\n    await asyncssh.listen(\"127.0.0.1\", 8022, server_host_keys=[\"test_host_key\"],\n                          authorized_client_keys=\"test_client_key.pub\", sftp_factory=DropZone)\n    await asyncio.Event().wait()\n\nasyncio.run(serve())\n",[223,1356,1357,1364,1371,1375,1395,1406,1433,1437,1451,1483,1503,1510,1514],{"__ignoreMap":221},[226,1358,1359,1361],{"class":105,"line":228},[226,1360,318],{"class":317},[226,1362,1363],{"class":321}," asyncio\n",[226,1365,1366,1368],{"class":105,"line":325},[226,1367,318],{"class":317},[226,1369,1370],{"class":321}," asyncssh\n",[226,1372,1373],{"class":105,"line":332},[226,1374,329],{"emptyLinePlaceholder":328},[226,1376,1377,1380,1383,1385,1388,1390,1393],{"class":105,"line":354},[226,1378,1379],{"class":317},"class",[226,1381,1382],{"class":231}," DropZone",[226,1384,634],{"class":321},[226,1386,1350],{"class":1387},"sHg5J",[226,1389,956],{"class":321},[226,1391,1392],{"class":1387},"SFTPServer",[226,1394,351],{"class":321},[226,1396,1397,1400,1403],{"class":105,"line":365},[226,1398,1399],{"class":317},"    def",[226,1401,1402],{"class":261}," __init__",[226,1404,1405],{"class":321},"(self, channel):\n",[226,1407,1408,1411,1414,1417,1420,1423,1425,1428,1431],{"class":105,"line":371},[226,1409,1410],{"class":261},"        super",[226,1412,1413],{"class":321},"().",[226,1415,1416],{"class":261},"__init__",[226,1418,1419],{"class":321},"(channel, ",[226,1421,1422],{"class":387},"chroot",[226,1424,345],{"class":317},[226,1426,1427],{"class":317},"b",[226,1429,1430],{"class":235},"\"test_remote\"",[226,1432,443],{"class":321},[226,1434,1435],{"class":105,"line":381},[226,1436,329],{"emptyLinePlaceholder":328},[226,1438,1439,1442,1445,1448],{"class":105,"line":412},[226,1440,1441],{"class":317},"async",[226,1443,1444],{"class":317}," def",[226,1446,1447],{"class":338}," serve",[226,1449,1450],{"class":321},"():\n",[226,1452,1453,1456,1459,1462,1464,1467,1469,1472,1474,1477,1480],{"class":105,"line":446},[226,1454,1455],{"class":317},"    await",[226,1457,1458],{"class":321}," asyncssh.listen(",[226,1460,1461],{"class":235},"\"127.0.0.1\"",[226,1463,423],{"class":321},[226,1465,1466],{"class":261},"8022",[226,1468,423],{"class":321},[226,1470,1471],{"class":387},"server_host_keys",[226,1473,345],{"class":317},[226,1475,1476],{"class":321},"[",[226,1478,1479],{"class":235},"\"test_host_key\"",[226,1481,1482],{"class":321},"],\n",[226,1484,1485,1488,1490,1493,1495,1498,1500],{"class":105,"line":681},[226,1486,1487],{"class":387},"                          authorized_client_keys",[226,1489,345],{"class":317},[226,1491,1492],{"class":235},"\"test_client_key.pub\"",[226,1494,423],{"class":321},[226,1496,1497],{"class":387},"sftp_factory",[226,1499,345],{"class":317},[226,1501,1502],{"class":321},"DropZone)\n",[226,1504,1505,1507],{"class":105,"line":1150},[226,1506,1455],{"class":317},[226,1508,1509],{"class":321}," asyncio.Event().wait()\n",[226,1511,1512],{"class":105,"line":1156},[226,1513,329],{"emptyLinePlaceholder":328},[226,1515,1516],{"class":105,"line":1170},[226,1517,1518],{"class":321},"asyncio.run(serve())\n",[10,1520,1521,1522,1525,1526,1528,1529,1532,1533,1536],{},"Generate the two keys once with ",[223,1523,1524],{},"asyncssh.generate_private_key(\"ssh-ed25519\")",", write the host key's public half into a test ",[223,1527,304],{},", and point ",[223,1530,1531],{},"upload_report"," at ",[223,1534,1535],{},"127.0.0.1:8022",". The test server gives the same errors as a real one for an unknown host key, a changed key and a rename onto an existing file, so the failure paths can be tested too — not only the happy path.",[20,1538,29,1543,29,1546,29,1549,29,1552,29,1556,29,1562,29,1568,29,1573,29,1578,29,1581,29,1584,29,1590,29,1594,29,1597,29,1601,29,1604,29,1607,29,1613,29,1617,29,1620,29,1624,29,1627,29,1630,29,1635,29,1640,29,1644,29,1648,29,1652,29,1655,29,1658,29,1663,29,1668],{"viewBox":1539,"role":23,"ariaLabelledBy":1540,"xmlns":27,"style":28},"4 0 752 142",[1541,1542],"sf-flow-t","sf-flow-d",[31,1544,1545],{"id":1541},"One delivery, end to end",[35,1547,1548],{"id":1542},"Connect with a pinned host key and a private key, create the dated folder if needed, upload under a .part name with a size check, rename to the final name, and read the file back to compare hashes where the server allows reads.",[39,1550],{"x":41,"y":42,"width":43,"height":1551,"fill":45},"142",[39,1553],{"x":48,"y":49,"width":1554,"height":1555,"rx":52,"fill":53,"stroke":54,"style":55},"115.2","110.5",[57,1557,1559],{"transform":1558},"translate(65.6,37.8) scale(1.000)",[61,1560],{"d":1561,"fill":64,"stroke":65,"style":66},"M8 11a4 4 0 1 0 .01 0z M11 13l9-9 M17 7l2 2 M15 9l2 2",[68,1563,1567],{"x":1564,"y":1565,"style":1566},"77.6","84.2","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Connect",[68,1569,1572],{"x":1564,"y":1570,"style":1571},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","pinned host key",[105,1574],{"x1":1575,"y1":1576,"x2":1577,"y2":1576,"stroke":54,"style":55},"140.2","71.2","162.2",[120,1579],{"points":1580,"fill":123},"166.2,71.2 157.2,76.2 157.2,66.2",[39,1582],{"x":1583,"y":49,"width":1554,"height":1555,"rx":52,"fill":53,"stroke":54,"style":55},"171.2",[57,1585,1587],{"transform":1586},"translate(216.8,37.8) scale(1.000)",[61,1588],{"d":1589,"fill":64,"stroke":65,"style":66},"M3 6h6l2 2h10v11H3z",[68,1591,1593],{"x":1592,"y":1565,"style":1566},"228.8","Folder",[68,1595,1596],{"x":1592,"y":1570,"style":1571},"inbound\u002F2026\u002F09",[105,1598],{"x1":1599,"y1":1576,"x2":1600,"y2":1576,"stroke":54,"style":55},"291.4","313.4",[120,1602],{"points":1603,"fill":123},"317.4,71.2 308.4,76.2 308.4,66.2",[39,1605],{"x":1606,"y":49,"width":1554,"height":1555,"rx":52,"fill":131,"stroke":132,"style":55},"322.4",[57,1608,1610],{"transform":1609},"translate(368.0,37.8) scale(1.000)",[61,1611],{"d":1612,"fill":64,"stroke":744,"style":66},"M3 11l18-8-8 18-2-8z",[68,1614,1616],{"x":88,"y":1565,"style":1615},"font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Upload .part",[68,1618,1619],{"x":88,"y":1570,"style":1571},"size confirmed",[105,1621],{"x1":1622,"y1":1576,"x2":1623,"y2":1576,"stroke":54,"style":55},"442.6","464.6",[120,1625],{"points":1626,"fill":123},"468.6,71.2 459.6,76.2 459.6,66.2",[39,1628],{"x":1629,"y":49,"width":1554,"height":1555,"rx":52,"fill":53,"stroke":54,"style":55},"473.6",[57,1631,1633],{"transform":1632},"translate(519.2,29.0) scale(1.000)",[61,1634],{"d":790,"fill":64,"stroke":65,"style":66},[68,1636,1639],{"x":1637,"y":1638,"style":1566},"531.2","75.5","Rename",[68,1641,1643],{"x":1637,"y":1642,"style":1571},"95.0","partner sees it",[68,1645,1647],{"x":1637,"y":1646,"style":1571},"112.5","now",[105,1649],{"x1":1650,"y1":1576,"x2":1651,"y2":1576,"stroke":54,"style":55},"593.8","615.8",[120,1653],{"points":1654,"fill":123},"619.8,71.2 610.8,76.2 610.8,66.2",[39,1656],{"x":1657,"y":49,"width":1554,"height":1555,"rx":52,"fill":185,"stroke":186,"style":55},"624.8",[57,1659,1661],{"transform":1660},"translate(670.4,37.8) scale(1.000)",[61,1662],{"d":768,"fill":64,"stroke":791,"style":66},[68,1664,1667],{"x":1665,"y":1565,"style":1666},"682.4","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Read back",[68,1669,1670],{"x":1665,"y":1570,"style":1571},"hashes match",[211,1672,1674],{"id":1673},"common-pitfalls","Common pitfalls",[1294,1676,1677,1686,1692,1705,1715],{},[1297,1678,1679,1682,1683,1685],{},[1300,1680,1681],{},"AutoAddPolicy in production."," It silently trusts the first key it sees and every key after a ",[223,1684,304],{}," is lost. Pin keys.",[1297,1687,1688,1691],{},[1300,1689,1690],{},"Passwords in the script."," If the partner insists on password login, read it from the environment or a keyring and ask them for key-based access.",[1297,1693,1694,1697,1698,1701,1702,1704],{},[1300,1695,1696],{},"Host key rotation."," When the partner changes servers, uploads fail with ",[223,1699,1700],{},"BadHostKeyException"," until you update ",[223,1703,304],{}," from the new key they send you — never by switching to AutoAddPolicy \"for now\".",[1297,1706,1707,1710,1711,1714],{},[1300,1708,1709],{},"Leaving connections open."," A job that opens a connection per file and never closes it can hit the server's session limit halfway through a batch. The ",[223,1712,1713],{},"with"," blocks in Step 4 close both the SFTP channel and the SSH connection, even when an upload fails.",[1297,1716,1717,1720],{},[1300,1718,1719],{},"Line-ending conversions."," SFTP transfers bytes unchanged, unlike FTP's text mode. Do not open the workbook in text mode anywhere in the pipeline.",[211,1722,1724],{"id":1723},"conclusion","Conclusion",[10,1726,1727,1728,1730,1731,1734,1735,1737,1738,1740,1741,1743,1744,1746],{},"Pin the server's host key in a ",[223,1729,304],{}," file and keep ",[223,1732,1733],{},"RejectPolicy",", log in with a dedicated private key, upload from memory with ",[223,1736,691],{}," under a ",[223,1739,496],{}," name, confirm the size, rename with ",[223,1742,702],{}," (or ",[223,1745,706],{}," when replacing must fail), create dated folders level by level, and read the file back when the server allows it. Agree names, markers and clean-up with the partner.",[211,1748,1750],{"id":1749},"frequently-asked-questions","Frequently asked questions",[10,1752,1753,1756],{},[1300,1754,1755],{},"How do I upload a file over SFTP in Python?","\nWith paramiko, create an SSHClient, load known host keys, connect with a username and private key, call open_sftp() and then put(local_path, remote_path) or putfo(file_object, remote_path) for data in memory.",[10,1758,1759,1762],{},[1300,1760,1761],{},"Is AutoAddPolicy safe for an automated SFTP upload?","\nNo. AutoAddPolicy trusts whatever key the server presents on first contact, so the script would happily send reports to an impostor. Store the partner's host key in a known_hosts file you control and keep paramiko's default RejectPolicy.",[10,1764,1765,1768],{},[1300,1766,1767],{},"How do I stop the receiving side picking up a half-uploaded file?","\nUpload under a temporary name such as report.xlsx.part and rename it to the final name once the upload is complete and verified. Partners that poll the folder for .xlsx files then only ever see complete files.",[10,1770,1771,1774],{},[1300,1772,1773],{},"Why does sftp.rename fail with \"File already exists\"?","\nSFTP's standard rename refuses to replace an existing file. Use sftp.posix_rename, supported by OpenSSH servers, to replace the target in one step, or remove the old file first if the server lacks the extension.",[211,1776,1778],{"id":1777},"related","Related",[1294,1780,1781,1787,1794,1801],{},[1297,1782,1783,1784,1786],{},"Up one level: ",[14,1785,17],{"href":16}," — where finished reports go.",[1297,1788,1789,1793],{},[14,1790,1792],{"href":1791},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fwrite-excel-files-to-a-network-share-from-python\u002F","Write Excel Files to a Network Share from Python"," — the same temporary-name trick on a Windows share.",[1297,1795,1796,1800],{},[14,1797,1799],{"href":1798},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-to-azure-blob-storage\u002F","Upload an Excel Report to Azure Blob Storage with Python"," — when the partner can read from cloud storage instead.",[1297,1802,1803,1807],{},[14,1804,1806],{"href":1805},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fskip-a-report-when-the-input-file-has-not-changed\u002F","Skip a Report Run When the Input File Has Not Changed"," — the receiving side of a file drop.",[1809,1810,1811],"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 .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .sHg5J, html code.shiki .sHg5J{--shiki-default:#6F42C1;--shiki-dark:#91CBFF}",{"title":221,"searchDepth":325,"depth":325,"links":1813},[1814,1815,1816,1817,1818,1819,1820,1821,1822,1823,1824],{"id":213,"depth":325,"text":214},{"id":293,"depth":325,"text":294},{"id":481,"depth":325,"text":482},{"id":823,"depth":325,"text":824},{"id":959,"depth":325,"text":960},{"id":1288,"depth":325,"text":1289},{"id":1343,"depth":325,"text":1344},{"id":1673,"depth":325,"text":1674},{"id":1723,"depth":325,"text":1724},{"id":1749,"depth":325,"text":1750},{"id":1777,"depth":325,"text":1778},"2026-09-23","Deliver Excel reports to a partner's SFTP server with paramiko — key-based login, host key checking that actually protects you, uploading from memory, writing under a temporary name and renaming so the partner never picks up half a file, creating dated folders, verifying the upload and handling the rename and permission errors SFTP servers return.","md",[1829,1831,1833,1835],{"q":1755,"a":1830},"With paramiko, create an SSHClient, load known host keys, connect with a username and private key, call open_sftp() and then put(local_path, remote_path) or putfo(file_object, remote_path) for data in memory.",{"q":1761,"a":1832},"No. AutoAddPolicy trusts whatever key the server presents on first contact, so the script would happily send reports to an impostor. Store the partner's host key in a known_hosts file you control and keep paramiko's default RejectPolicy.",{"q":1767,"a":1834},"Upload under a temporary name such as report.xlsx.part and rename it to the final name once the upload is complete and verified. Partners that poll the folder for .xlsx files then only ever see complete files.",{"q":1773,"a":1836},"SFTP's standard rename refuses to replace an existing file. Use sftp.posix_rename, supported by OpenSSH servers, to replace the target in one step, or remove the old file first if the server lacks the extension.","upload an excel report over sftp with python",null,{"breadcrumb":1840},[1841,1843,1846],{"name":1842,"item":549},"Home",{"name":1844,"item":1845},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":17,"item":16},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp",{"title":1849,"description":1850},"Upload an Excel File over SFTP with Python (paramiko)","Send xlsx reports over SFTP from Python with paramiko — Ed25519 keys, known_hosts and RejectPolicy instead of AutoAddPolicy, putfo from BytesIO with confirm, .part upload then posix_rename, mkdir -p on the server, read-back hashes and the exact errors for unknown hosts, changed keys and existing files.","upload-an-excel-report-over-sftp","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-over-sftp\u002Findex",[1854],"publishing","how-to","w6poIEWIJ2qOc-nn04VrEc0K9Dcdj4raw2APsy9UEew",[1858,1862],{"title":1859,"path":1860,"stem":1861,"children":-1},"Save Excel Reports to Google Drive with Python","\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fsave-excel-reports-to-google-drive-with-python","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fsave-excel-reports-to-google-drive-with-python\u002Findex",{"title":1863,"path":1864,"stem":1865,"children":-1},"Upload an Excel Report to Amazon S3 with boto3","\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-to-amazon-s3-with-boto3","automating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fupload-an-excel-report-to-amazon-s3-with-boto3\u002Findex",[1867,1869],{"path":1868,"title":1844},"\u002Fautomating-reporting-workflows",{"path":1870,"title":17},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage",[],[1873,1876,1879,1882,1885,1888],{"path":1874,"title":1875,"kind":1855},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fread-an-excel-file-from-sharepoint-or-onedrive-into-pandas","Read an Excel File from SharePoint or OneDrive into pandas",{"path":1877,"title":1878,"kind":1855},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fshare-an-excel-file-in-onedrive-with-a-link-using-graph","Share an Excel File in OneDrive with a Link Using Graph",{"path":1880,"title":1881,"kind":1855},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Femail-a-download-link-instead-of-an-excel-attachment","Email a Download Link Instead of an Excel Attachment",{"path":1883,"title":1884,"kind":1855},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-amazon-ses","Send an Excel Report with Amazon SES",{"path":1886,"title":1887,"kind":1855},"\u002Fautomating-reporting-workflows\u002Fgenerating-excel-reports-from-templates\u002Fgenerate-an-html-report-from-excel-data-with-jinja2","Generate an HTML Report from Excel Data with Jinja2",{"path":1889,"title":1890,"kind":1855},"\u002Fautomating-reporting-workflows\u002Fpublishing-excel-reports-to-cloud-storage\u002Fname-version-and-archive-generated-excel-reports","Name, Version and Archive Generated Excel Reports",1790972138427]