How-to
Advanced Data Transformation And CleaningDeep dive

Download New Excel Files from an SFTP Server

Collect the spreadsheets a partner drops on an SFTP server with paramiko: connect with a pinned host key, list the folder with sizes and modification times, ignore partial uploads and files still being written, download only files not fetched before — or fetched before but changed since — into a temporary name and rename, check each workbook opens, keep a small state file, and test it all against a local SFTP server.

Banks, logistics providers, payroll bureaus and marketplaces often deliver their reports by dropping files on an SFTP server: a new stock file every night, a settlement report every morning. The receiving side needs a job that runs on a schedule, picks up the files it has not seen before, and leaves the rest alone. The details are where these jobs fail: downloading a file that the partner is still uploading and getting half a workbook, fetching everything again on every run, missing a file the partner re-uploaded with corrections under the same name, or connecting to whatever server answers because the host key was never checked. This page builds a downloader with paramiko that handles each of those: a pinned host key, a listing with sizes and times, a rule for files still being written, a state file that records what was fetched, downloads that only appear under their final name when complete, and a check that each workbook opens. Everything is tested against an SFTP server running locally. It belongs to Moving Data Between Excel and Databases.

Prerequisites

Bash
pip install paramiko openpyxl

The partner publishes files in an outgoing folder on their SFTP server. The job authenticates with its own SSH key, and the partner's host key has been received out of band — by email from the partner or read from their documentation — and stored in a known_hosts file. The local test server here is started with asyncssh; the downloader does not know or care that it is local.

Step 1 — Connect, refusing unknown servers

Python
import paramiko

def connect(host="127.0.0.1", port=8022, user="reports"):
    client = paramiko.SSHClient()
    client.load_host_keys("known_hosts")                       # the partner's published host key
    client.set_missing_host_key_policy(paramiko.RejectPolicy())
    client.connect(host, port=port, username=user, key_filename="id_ed25519_reports",
                   look_for_keys=False, allow_agent=False, timeout=30)
    return client

client = connect()
sftp = client.open_sftp()
print(sorted(sftp.listdir("/outgoing")))

The connection loads the partner's host key from known_hosts and uses RejectPolicy, which refuses to connect if the server presents any other key. That is the point of SFTP's security: without the check, the job would hand its credentials to, and accept files from, any machine that answered at that address. The job logs in with a dedicated key, not a person's, and turns off the agent and default key search so it only ever uses that one. A timeout stops a stuck connection from hanging the job. The listing shows what the partner has published: three finished workbooks, one workbook that was written moments ago, a partial upload with a .part suffix and a text file.

One run of the downloader The job connects with a pinned host key, lists the folder with sizes and times, keeps only finished Excel files not fetched before, downloads each to a temporary name, checks it opens, renames it, and records it in the state file. Connect host key must match List with attributes name, size, mtime Filter xlsx, settled, new or changed Download to .part then rename Check + record opens; state file

Step 2 — Decide which files to fetch

Python
import json, stat, time
from pathlib import Path

STATE = Path("sftp_state.json")
seen = json.loads(STATE.read_text()) if STATE.exists() else {}
SETTLE = 120                                                    # seconds without change before a file counts as finished

def candidates(sftp, folder="/outgoing"):
    now = time.time()
    picked, skipped = [], []
    for a in sftp.listdir_attr(folder):
        name, reason = a.filename, None
        if not stat.S_ISREG(a.st_mode):
            reason = "not a file"
        elif not name.lower().endswith(".xlsx") or name.startswith("~$"):
            reason = "not a workbook"
        elif now - a.st_mtime < SETTLE:
            reason = "still being written"
        elif seen.get(name) == [a.st_size, int(a.st_mtime)]:
            reason = "already downloaded"
        (skipped if reason else picked).append((name, a.st_size, int(a.st_mtime), reason))
    return picked, skipped

picked, skipped = candidates(sftp)
print("fetch:", [p[0] for p in picked])
print("skip:", [(s[0], s[3]) for s in skipped])

listdir_attr returns each entry with its size, modification time and type in a single request, which is all the information the decision needs. Directories and other non-files are skipped, as is anything that is not an .xlsx workbook — the partial upload ends in .part, the readme is text. A workbook modified in the last two minutes is treated as still being written and left for the next run: there is no reliable way to know from the outside that an upload has finished, but a file whose time has not moved for a couple of minutes almost always has. Finally, the state file records each downloaded file's size and modification time; a file with the same name, size and time as before is skipped, while a file the partner re-uploaded with corrections — same name, new time or size — is fetched again.

Step 3 — Download safely and check each workbook

Python
from openpyxl import load_workbook

dest = Path("incoming")
dest.mkdir(exist_ok=True)
for name, size, mtime, _ in picked:
    tmp = dest / (name + ".part")
    sftp.get(f"/outgoing/{name}", str(tmp))
    if tmp.stat().st_size != size:
        tmp.unlink()
        print(f"{name}: size changed during download, will retry next run")
        continue
    try:
        with open(tmp, "rb") as fh:                             # a file object: openpyxl rejects the .part name
            load_workbook(fh, read_only=True).close()           # a quick check that it is a real workbook
    except Exception as e:
        tmp.rename(dest / (name + ".bad"))
        print(f"{name}: not a valid workbook ({type(e).__name__})")
        continue
    final = dest / name
    if final.exists():                                          # a corrected re-upload: keep the earlier copy
        final.rename(dest / f"{final.stem}.{time.strftime('%Y%m%d%H%M%S', time.localtime(seen[name][1]))}{final.suffix}")
    tmp.rename(final)
    seen[name] = [size, mtime]
    print(f"{name}: downloaded ({size:,} bytes)")
STATE.write_text(json.dumps(seen, indent=1))
client.close()

Each file is downloaded to a .part name and renamed only when it is complete and checked, so anything that watches the incoming folder never sees half a file. Two checks run before the rename: the downloaded size must match the size in the listing — if the partner changed the file mid-download, the copy is discarded and the next run tries again — and the workbook must open with openpyxl in read-only mode, which takes a moment even for large files. The check opens the file through a file object rather than by path, because openpyxl refuses to open a path whose extension is not one it recognises, and .part is not; an earlier version of this code, passing the path, rejected every download as invalid. A file that fails the second check is kept under a .bad name for investigation rather than deleted. When a corrected version of a file already downloaded arrives, the earlier copy is renamed with its timestamp rather than overwritten, so nothing that has already been processed is lost. The state file is written at the end of the run with every file that made it.

What the first run decides The two stock files and the settlement file are downloaded. The stock file for 3 October is skipped as still being written, the .part file and the readme are skipped as not workbooks. A B C 1 File Decision Reason 2 stock_2026-10-01.xlsx download new 3 stock_2026-10-02.xlsx download new 4 settlement_2026-10-02.xlsx download new 5 stock_2026-10-03.xlsx skip still being written 6 stock_2026-10-03.xlsx.part skip not a workbook 7 readme.txt skip not a workbook Remembering what was fetched A local state file of name, size and modification time works with read-only access and also notices corrected re-uploads. Moving fetched files to a processed folder on the server makes the server the record but needs write access, which partners often do not give. Local state file works with read-only access notices corrected re-uploads must be kept with the job Move to processed/ on the server the server is the record needs write access partner sees what was taken

Step 4 — Run it again

Python
client = connect()
sftp = client.open_sftp()
picked, skipped = candidates(sftp)
print("second run fetches:", [p[0] for p in picked])
print("already downloaded:", [s[0] for s in skipped if s[3] == "already downloaded"])
client.close()

A second run straight afterwards fetches nothing new: the three files from the first run are recognised from the state file, and the newest stock file is still within its settling time. Once it has been untouched for two minutes, the next scheduled run picks it up. That is the behaviour a scheduled job needs — safe to run every fifteen minutes, never fetching twice, never fetching early. If the partner allows it, an alternative to the state file is to move each fetched file into a processed folder on the server with sftp.rename, which makes the server itself the record; many partners give read-only access, so the state file is the approach that always works.

Common pitfalls

  • AutoAddPolicy. It trusts any server; pin the partner's host key and use RejectPolicy.
  • Fetching files still being uploaded. Wait until a file's time has not changed for a while.
  • Deduplicating by name only. Corrected re-uploads keep the name; compare size and time too.
  • Writing straight to the final name. Download to .part and rename when complete.
  • Overwriting earlier versions. Keep the previous copy when a corrected file arrives.

Conclusion

To pick up new Excel files from an SFTP server, connect with paramiko using a pinned host key and RejectPolicy, list the folder with listdir_attr, and fetch only .xlsx files that have settled and whose name, size and time are not in the state file. Download each to a temporary name, check its size and that it opens, then rename it, keeping any earlier version, and record it in the state file so the job can run as often as needed without fetching anything twice.

Frequently asked questions

How do I download only new files from SFTP in Python? Keep a record of each downloaded file's name, size and modification time, and fetch only files whose entry is missing or different.

How do I avoid downloading a file that is still being uploaded? Skip files whose modification time is within the last few minutes, and check the downloaded size against the listing.

Should I use AutoAddPolicy with paramiko? No; load the partner's host key from a known_hosts file and use RejectPolicy so the job refuses unknown servers.

How do I test SFTP code without a real server? Run a local SFTP server, for example with asyncssh, with its own host key and an authorised client key.

More on databases, apis & sources

More on databases, apis & sources →