Home › Topics › Python Automation › FTP & FTPS

FTP and FTPS in Python with the Standard Library

Sooner or later an automation project meets an endpoint that speaks FTP. It might be a partner's aging gateway, or a device that has offered the same interface for decades. Or it might be an FTPS server standing where a modern flow now needs to reach. The good news: unlike SFTP, this is a place where Python's batteries really are included. The standard library's ftplib module speaks both plain FTP and FTPS with no third-party packages at all. That means no dependency management, no virtual-environment ceremony, and one less thing to deploy.

This article is a working tour of that module: the session model, transfers and listings. It covers the exact steps that make TLS encryption real rather than decorative. It covers the timeout and error classes that separate a robust script from a hanging one, and a complete download job you can adapt. This article is part of our Python Automation series. It pairs with SFTP transfers in Python — the article to read when the endpoint speaks SSH instead.

One Module, Two Classes

Everything in ftplib hangs off two classes. FTP speaks the original, unencrypted protocol. FTP_TLS is a subclass that adds explicit FTPS. In this variant, the client connects to the normal FTP port and then upgrades the conversation to TLS encryption before logging in. Because one inherits from the other, everything you learn about sessions, transfers, and listings applies to both. FTPS adds two extra steps and a certificate decision, covered below.

It helps to know that the module is a thin, honest mapping onto the protocol itself. When your script calls login(), the module sends the same USER and PASS commands you would see in any client log. When a transfer starts, the same PASV negotiation and numeric reply codes travel the wire. That transparency is a gift when troubleshooting — a wire-level trace of your script reads exactly like our guide to FTP commands and reply codes. During development, one line — ftp.set_debuglevel(1) — prints the whole conversation:

*cmd* 'AUTH TLS'
*resp* '234 AUTH command ok'
*cmd* 'USER reports'
*resp* '331 Password required for reports'
*cmd* 'PASS app-password'
*resp* '230 User logged in'
*cmd* 'PBSZ 0'
*resp* '200 PBSZ=0'
*cmd* 'PROT P'
*resp* '200 PROT now Private'

Reading a trace like that turns "the script doesn't work" into "the server answered 530 to my PASS," which is a solvable problem. One caution before you paste a trace into a ticket: as the third line shows, the debug output prints your commands verbatim, password included. Scrub it first, and switch the debug level back to zero before the script goes anywhere near a schedule.

A Plain FTP Session, Line by Line

Here is a minimal, complete session — connect, log in, fetch one file, leave:

from ftplib import FTP

ftp = FTP("ftp.example.com", timeout=30)   # connects; timeout in seconds
ftp.login("reports", "app-password")       # sends USER and PASS
ftp.cwd("/outbox")                         # change remote directory
names = ftp.nlst()                         # list bare file names
with open("daily.csv", "wb") as fh:
    ftp.retrbinary("RETR daily.csv", fh.write)
ftp.quit()                                 # polite goodbye (sends QUIT)

Line by line. The constructor takes the hostname and — critically — a timeout in seconds that applies to the socket operations. Without it, a dead server or a silently dropping firewall leaves your script waiting forever. And "forever" is a genuine production incident when a scheduler is stacking up overlapping runs behind it. login() authenticates; on a plain FTP session both the password and every file travel unencrypted, a point we return to shortly. cwd() changes the server-side working directory, and nlst() returns a plain list of names in it.

The transfer line deserves a slower read. retrbinary() takes two arguments. The first is the literal protocol command to send — "RETR daily.csv", retrieve this file. The second is a callback that is called with each chunk of bytes as it arrives. Passing the open file's write method as the callback streams the download straight to disk, chunk by chunk. So memory use stays flat no matter how large the file is. Opening the file in binary mode ("wb") matters: text mode would corrupt anything that is not plain text, and even plain text can suffer line-ending surprises.

Finally, quit() ends the session politely by sending the QUIT command and waiting for the server's farewell. Its blunt sibling close() just drops the connection. The robust pattern — used in the complete script below — is to try quit() and fall back to close() if the session is already broken.

One default worth knowing: ftplib uses passive mode for data connections, which is the mode that works through firewalls and NAT, and almost always what you want. ftp.set_pasv(False) exists for the rare active-mode-only endpoint; if those words mean nothing yet, our active vs passive FTP explainer is the background read.

Remember: plain FTP sends credentials and file contents in cleartext. Scripting it is sometimes unavoidable with legacy endpoints, but treat every plain-FTP flow as a migration candidate. The narrow cases where it remains acceptable are cataloged in when plain FTP is acceptable.

Uploads That Land Safely

Uploading mirrors downloading: storbinary() takes the STOR command and an open file handle to read from. This time the module pulls chunks from your file and sends them:

with open("/data/outbox/report.csv", "rb") as fh:
    ftp.storbinary("STOR report.csv.part", fh)
ftp.rename("report.csv.part", "report.csv")

Notice the two-step landing. The file uploads under a temporary name, then a rename() — a single, quick protocol operation — gives it its real name. Any process watching that directory on the server side sees report.csv appear whole or not at all, never half-written. This temp-name-and-rename habit costs one extra line and prevents an entire class of downstream corruption; make it your default for every automated upload.

To confirm the upload's size, ftp.size("report.csv") asks the server. One classic quirk: some servers refuse SIZE in the default ASCII transfer type, so send ftp.voidcmd("TYPE I") — switch to binary type — before asking. A matching size is a decent completeness check; matching content is a hash comparison, as explained in hashing explained.

That mention of transfer types is worth thirty seconds more, because it explains a whole family of "my file arrived corrupted" tickets. FTP defines two modes for moving bytes: ASCII type, which translates line endings between systems as it copies, and binary type (also called image type), which copies bytes exactly. The *binary methods used throughout this article — storbinary, retrbinary — set binary type. That is what you want for essentially everything: spreadsheets, archives, PDFs, and even CSV files, whose line endings your parsers can handle themselves. The storlines/retrlines siblings use ASCII type. In automation, reserve them for the one thing they are genuinely right for — reading listings. Let data files travel binary, always.

Switching On TLS: FTP_TLS, Done Right

Explicit FTPS wraps the FTP conversation in TLS — the same encryption layer HTTPS uses. Getting it right in ftplib takes three deliberate moves, and the third is the one that separates working scripts from quietly insecure ones:

import ssl
from ftplib import FTP_TLS

ctx = ssl.create_default_context()         # verifies the server's certificate
ftps = FTP_TLS("ftps.example.com", context=ctx, timeout=30)
ftps.login("reports", "app-password")      # upgrades to TLS before USER/PASS
ftps.prot_p()                              # encrypt the data channel too
ftps.cwd("/inbox")
with open("/data/outbox/report.csv", "rb") as fh:
    ftps.storbinary("STOR report.csv.part", fh)
ftps.rename("report.csv.part", "report.csv")
ftps.quit()

Move one: pass a real SSL context. ssl.create_default_context() checks that the server's certificate is signed by a trusted authority and that its name matches the host you dialed. Do not skip this argument: for compatibility reasons, the module's own default is far more permissive. A script that never verifies certificates will happily encrypt its secrets straight to an impostor. Verification is the half of TLS that people forget exists.

Move two: login(). On an FTP_TLS object, login first performs the AUTH TLS upgrade on the control connection, so your username and password already travel encrypted. Nothing extra to do — but only because you used FTP_TLS, not FTP.

Move three — the classic omission: prot_p(). After login, only the control channel (commands and replies) is encrypted; the data channel that carries your actual files still defaults to cleartext. prot_p() issues the protocol's PBSZ 0 and PROT P commands to encrypt data connections as well. A script that omits it encrypts the password and then ships every file in the clear — while looking secure in the source code. How the two channels and the TLS wrapper fit together is diagrammed in explicit vs implicit FTPS. Note that ftplib supports the explicit variant only, so an implicit-FTPS endpoint on port 990 needs different tooling.

When a partner presents a self-signed certificate, the tempting fix is disabling verification. The honest fix keeps verification on and trusts that one specific certificate. Obtain it from the partner over a trustworthy channel and load it with ctx.load_verify_locations("partner-cert.pem"). Your script then accepts exactly that server and still rejects impostors.

Listings You Can Parse

Automation lives and dies by directory listings, and ftplib offers three of very different quality. nlst() returns bare names — fine for filtering by pattern, silent about sizes and dates. dir() (or retrlines("LIST", ...)) returns the human-formatted listing you would see in a terminal. Its format varies by server, and scripts that parse it break the day the server changes. The one to prefer is mlsd(), which servers provide precisely for machines: it yields each name with a dictionary of standardized facts.

for name, facts in ftps.mlsd("/outbox"):
    if facts.get("type") == "file" and name.endswith(".csv"):
        print(name, facts.get("size"), facts.get("modify"))

The type fact distinguishes files from directories; size and modify support decisions like "skip files still growing" or "fetch only what is newer than the last run." Not every server implements MLSD — wrap the call and fall back to nlst() when it raises a permanent error, as the complete script below does. And the more disciplined your remote file names are, the less listing logic you need at all. That is the case made in our file naming and datestamping series.

Timeouts and the Error Classes

The module's exceptions map straight onto the protocol's reply codes, which makes error handling unusually principled. A reply in the 400s means temporary — try again later — and raises ftplib.error_temp. A reply in the 500s means permanent — wrong credentials, missing file, refused command — and raises ftplib.error_perm. Network-level failures (connection reset, timeout, dropped socket) surface as OSError family exceptions rather than ftplib's own. The module bundles everything transfer-breaking into one convenient tuple, ftplib.all_errors:

import ftplib

try:
    with open(local_path, "rb") as fh:
        ftps.storbinary(f"STOR {remote_name}.part", fh)
    ftps.rename(f"{remote_name}.part", remote_name)
except ftplib.error_perm as exc:
    log.error("permanent failure for %s: %s", remote_name, exc)   # do not retry
except ftplib.all_errors as exc:
    log.warning("transient failure for %s: %s", remote_name, exc) # retry candidate

Order matters: the narrow error_perm handler must come first, because all_errors would also match it. This two-way split — permanent failures logged and abandoned, everything else treated as retryable — is the foundation the whole retry discipline builds on. The strategy side lives in our retry and error handling series, and the Python mechanics in writing robust Python transfer scripts.

The 4xx-transient rule has one practical footnote: a 530 Login incorrect is permanent in the fix-your-credentials sense, and retrying it aggressively can lock the account. When in doubt, read the reply text your exception carries — it quotes the server's own words.

A Complete Download Job

The pieces assembled — deliberately in the same skeleton as the SFTP script from the companion article, because the shape of a good transfer job does not change with the protocol:

import ftplib
import logging
import os
import ssl
import sys
from pathlib import Path

HOST = "ftps.partner.example.com"
USER = "reports"
PASSWORD = os.environ["FTPS_PASSWORD"]     # from the scheduler, not the source
REMOTE_DIR = "/outbox"
LOCAL_DIR = Path("/data/incoming")

log = logging.getLogger("ftps-pull")

def main():
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(message)s",
                        datefmt="%b %d %H:%M:%S")
    try:
        ftps = ftplib.FTP_TLS(HOST, context=ssl.create_default_context(),
                              timeout=30)
        ftps.login(USER, PASSWORD)
        ftps.prot_p()
        ftps.cwd(REMOTE_DIR)
    except ftplib.error_perm as exc:
        log.error("login or path rejected: %s", exc)
        return 3
    except ftplib.all_errors as exc:
        log.error("could not reach %s: %s", HOST, exc)
        return 4

    failures = 0
    try:
        try:
            listing = [n for n, f in ftps.mlsd() if f.get("type") == "file"]
        except ftplib.error_perm:
            listing = ftps.nlst()             # server without MLSD support
        for name in sorted(listing):
            if not name.endswith(".csv"):
                continue
            part = LOCAL_DIR / (name + ".part")
            try:
                with open(part, "wb") as fh:
                    ftps.retrbinary(f"RETR {name}", fh.write)
                os.replace(part, LOCAL_DIR / name)   # atomic local landing
                log.info("fetched %s", name)
            except ftplib.all_errors as exc:
                failures += 1
                log.error("failed %s: %s", name, exc)
    finally:
        try:
            ftps.quit()
        except ftplib.all_errors:
            ftps.close()                      # session already broken

    return 1 if failures else 0

if __name__ == "__main__":
    sys.exit(main())

The password arrives through an environment variable set by the scheduler, never hard-coded — the habits behind that line are in service account hygiene. Downloads land under a .part name and are atomically renamed into place, so nothing downstream ever reads a half-file. Connection failures and per-file failures return different exit codes. The finally block ends the session politely with a fallback when politeness is no longer possible.

When the Standard Library Is Enough — and When It Is Not

For a scheduled upload or download against an explicit-FTPS or FTP endpoint, ftplib is genuinely enough: no dependencies, stable across Python upgrades, transparent on the wire. Its limits are equally clear. It does not speak implicit FTPS. It has no SFTP — different protocol entirely, different article. It offers no built-in retries, mirroring, or parallel transfers; you write those or bring a tool that has them.

And sometimes the right amount of Python is none. Suppose the flow is a standard shape — poll a server, fetch what is new, notify on failure. A configurable Windows tool such as Sysax FTP Automation already covers FTP, FTPS, and SFTP with wizard-built tasks, scheduling, folder monitoring, and email notifications. It will still be maintained when your script's author changes teams. Save the custom code for flows with logic a wizard cannot express.

If you also operate the server side of these transfers, the pairing matters too. On Windows, Sysax Multi Server accepts FTPS and FTP (alongside SFTP and HTTPS) and lets you configure the passive port range your firewall expects. It writes the activity logs that tell you what a client script actually did. The server's view of your STOR and RETR commands is the other half of every debugging session.

Where to Go Next

You now hold the whole standard-library story. That includes sessions and transfers, TLS switched on properly with a verifying context and prot_p(). It includes listings worth parsing, and errors sorted into retry-worthy and not. One natural next step is writing robust Python transfer scripts, which turns the error-class split into a full retry-and-logging discipline. Another is building a small transfer utility, which grows a script like today's into a configurable tool with a command line and a dry-run mode.

Frequently Asked Questions

Does ftplib verify FTPS certificates automatically?
Not strictly enough to rely on. Always pass context=ssl.create_default_context() to FTP_TLS; that context checks the certificate chain and the server's name. Without an explicit context, the module's permissive default can accept certificates a browser would refuse.
Why does my FTPS script log in fine but hang on listings?
It is almost always a data-connection problem. Listings travel on a second connection, and with TLS switched on, firewalls can no longer read the passive-mode negotiation to help it through. Check passive mode, the server's passive port range, and the firewall rules between you.
What is the difference between error_temp and error_perm?
They mirror the protocol's reply codes. An error_temp is a 4xx reply, meaning the condition is temporary and a later retry may succeed. An error_perm is a 5xx reply, meaning something is wrong that retrying will not fix — credentials, paths, permissions. Handle them differently and your retry logic is already half designed.
Can ftplib connect to an implicit FTPS server on port 990?
Not out of the box — FTP_TLS implements the explicit upgrade on the standard port. Implicit endpoints expect TLS from the first byte. If the partner offers only implicit FTPS, use a client or tool that supports it rather than patching the module.
Should a new integration choose FTPS or SFTP?
Both encrypt properly when configured well. SFTP is usually the smoother choice for automation — one connection, one port, no certificate-and-firewall interplay — and in practice the partner's existing infrastructure decides. If you get a vote, vote SFTP.
Why did my downloaded file arrive subtly corrupted?
The usual suspects are transfer type and file mode. An ASCII-type transfer rewrites line endings. Opening the local file in text mode instead of binary ("wb"/"rb") invites the same damage. Use retrbinary and storbinary with binary-mode file handles and byte-exact copies are what you get.

From the Sysax team: we build secure file transfer software for Windows. Sysax Multi Server is an FTP, FTPS, SFTP, and HTTPS server. Sysax FTP Automation handles scheduled, scripted transfers. Free trials are on the download page.