Advertisement
Open Source Projects by Phil Schwartz

Automating FTP Transfers with Python and Resume Support

Even in an era dominated by object storage and slick REST APIs, FTP still earns its place inside Australian workflows. Regional councils in places like Warrnambool or Cairns push nightly data dumps to central servers over NBN satellite links, while mining operations near Kalgoorlie rely on batch uploads of telemetry when their on-site servers sync with a backend in Perth. Python's built-in ftplib module makes these transfers scriptable, and pairing it with a bit of code for resume support turns flaky connections into a non-event. Combined with the strength of the standard library, you get automation that runs unattended for months at a time without intervention.

The trick with FTP in 2026 is treating it like the practical protocol it is: simple, widespread, and forgiving when you add the right logic. Resume support is the single most important feature to bolt on, because TCP sessions drop, routers reboot, and Australian NBN FTTN lines do not care about your transfer window. The snippets below focus on real-world patterns rather than textbook examples, and they assume you can already reach your FTP target over the public internet.

Connecting securely with ftplib

The ftplib module ships with Python, so there is nothing to install beyond whatever your runtime already provides. For most modern cases you will want FTP_TLS rather than the plain FTP class, since credentials otherwise travel in the clear. A typical session looks like this:

from ftplib import FTP_TLS
ftp = FTP_TLS('ftp.example.com')
ftp.login(user='alice', passwd='hunter2')
ftp.prot_p()

The prot_p() call switches the data channel to TLS as well, which matters when you are uploading anything sensitive, such as logs from a financial-services partner in Melbourne. If you maintain older endpoints, FTP still works, but wrap it behind a VPN or at minimum restrict it to a known IP range. The SSH honeypot write-up on this site shows how easy it is to find exposed services, so treat cleartext FTP as a risk rather than a default.

After login, ftp.nlst() lists entries in the current directory, while ftp.cwd() moves you around. You can also call ftp.mkd() to make folders or ftp.delete() to remove a single file. Keep in mind that FTP servers often have quirks, especially older IoT-style boxes that pretend to be NAS units in someone's garage in regional Tasmania. Always check ftp.size() before assuming a remote file exists.

Uploading files with progress callbacks

storbinary is the workhorse for non-ASCII payloads, which includes almost everything you care about in 2026. You pass it an open file handle and a callback that receives chunks of bytes. A common pattern is to count bytes and print or log progress, which is useful when uploads crawl along on a congested ADSL link in outer-suburban Brisbane.

def upload_with_progress(ftp, local_path, remote_path):
    total = os.path.getsize(local_path)
    sent = [0]
    def callback(data):
        sent[0] += len(data)
        pct = sent[0] * 100 / total
        print(f'\r{pct:5.1f}%', end='')
        ftp.storbinary(f'STOR {remote_path}', open(local_path, 'rb'), callback=callback)

The callback fires per block, not per byte, so progress jumps rather than crawls. For large files, a smoother approach is to update progress every few hundred kilobytes inside the callback and skip the rest. If you only need plain text, ftp.storlines() exists but rarely matters these days outside niche log shipping scenarios.

Downloading files efficiently

Downloads use retrbinary, which is the mirror image of storbinary. The callback receives data chunks as they arrive, and you write them out to a local file. For binary integrity it is worth opening the destination in 'wb' mode and never forgetting the binary flag on Windows systems that try to be helpful with text-mode translations.

def download(ftp, remote_path, local_path):
    with open(local_path, 'wb') as out:
        ftp.retrbinary(f'RETR {remote_path}', out.write)

For huge files you might prefer to stream directly into a database loader or a parser, but most home-grown scripts just dump to disk first and then process. If the connection drops halfway through, this naive version will restart from zero, which is where resume support earns its keep and saves several hours of bandwidth.

Implementing resume with the REST command

Resume on FTP works through the REST command, which tells the server to start the next transfer at a given byte offset. Combine it with a check of the local file size and you get a transfer that simply picks up where it left off. The key trick is that the server does not care whether the local file already contains the prefix; it just resumes writing or reading from that offset.

def upload_resume(ftp, local_path, remote_path):
    local_size = os.path.getsize(local_path)
    try:
        remote_size = ftp.size(remote_path)
    except Exception:
        remote_size = 0
    if remote_size >= local_size:
        return
    with open(local_path, 'rb') as f:
        f.seek(remote_size)
        ftp.voidcmd(f'REST {remote_size}')
        ftp.storbinary(f'APPE {remote_path}', f)

APPE is the often-forgotten sibling of STOR and appends rather than overwriting. Pair it with REST and you get upload resume. Downloads mirror the same idea with ftp.voidcmd(f'REST {local_size}') followed by retrbinary. A quick sanity checklist helps when wiring this up:

Error handling, retries, and the Australian reality

Connections drop. Routers in apartment buildings across Sydney reboot every Sunday arvo for firmware updates, and regional NBN Sky Muster satellite connections will hand you a timeout every couple of hours during peak evening use. Wrap your transfer logic in a retry loop that catches ftplib.error_perm separately from socket-level errors, since the first usually means a permissions or path problem while the second simply means "try again".

A robust pattern uses exponential backoff and a maximum attempt count, then logs the failure and alerts through whatever channel you prefer: SMS via Twilio, a webhook into a Slack channel, or even a simple email to your on-call rotation. In some Australian shops the on-call rotation is just one person based in Adelaide; in larger setups it is a shared roster across Sydney and Brisbane, with handovers keyed off AEDT to avoid confusion between states running on different daylight rules.

For very long-lived jobs, consider running the script under systemd with Restart=on-failure. A Raspberry Pi sitting in a comms cabinet somewhere in regional Victoria can then quietly re-run the failed transfer on the next boot without anyone touching it, and the ACSC's guidance on resilience for small sites is a sensible rubric to lean on when justifying the configuration.

Putting it together and reusing the code

Once you have upload, download, and resume sorted, the rest of the script is glue: argument parsing, configuration loading, and a small database or JSON file that records which transfers succeeded. The combination is genuinely useful for any Australian developer dealing with rural connectivity or with older business partners that have not migrated to SFTP yet, and it scales down to personal backups just as well as it scales up to enterprise use cases.

A few habits worth keeping for the long run:

If you find yourself spending more evenings than you would like chasing flaky transfers and bookkeeping, this kind of work can creep into every weekend. A look at how travelling bloggers handle burnout is useful background reading for any developer juggling ongoing automation side projects, since FTP jobs tend to be exactly the kind of thing that quietly eats your spare time.