Python ftplib: Secure FTP and FTPS

Published on: August 21, 2026
Reading time: 5 minutes
Extreme close-up of computer code displaying various programming terms and elements.

The ftplib module implements the client side of FTP in Python’s standard library. It can list directories, download, upload, rename, and delete files on compatible servers. FTP still appears in legacy integrations, hosting systems, appliances, and enterprise file exchanges.

Plain FTP sends credentials and data without encryption. On untrusted networks, prefer FTPS through FTP_TLS. If a server requires SFTP, use an SSH-specific library: SFTP is not FTP and is not implemented by ftplib. This guide prioritizes FTPS, timeouts, size limits, and validation.

A basic FTP connection

from ftplib import FTP

with FTP("ftp.example.com", timeout=15, encoding="utf-8") as ftp:
    ftp.login("user", "password")
    print(ftp.pwd())
    print(ftp.nlst())

This example uses unencrypted FTP and is appropriate only for a controlled network or anonymous server. Never send a real password over plain FTP across the internet.

FTPS with FTP_TLS

import ssl
from ftplib import FTP_TLS

context = ssl.create_default_context()
context.minimum_version = ssl.TLSVersion.TLSv1_2

with FTP_TLS(
    "ftp.example.com",
    timeout=15,
    context=context,
    encoding="utf-8",
) as ftps:
    ftps.login("user", "password")
    ftps.prot_p()
    print(list(ftps.mlsd()))

FTP_TLS protects the control channel, but the data channel becomes private only after prot_p(). Without that call, directory listings and file contents may travel without the expected encryption.

The default context verifies the certificate and hostname. Do not use an unverified context as a workaround for expired or mismatched certificates. Read Python ssl.

Credentials

Do not put usernames and passwords in source code. Load them from environment variables or a secret manager. A .netrc file can help local tools, but it also contains secrets and needs restrictive permissions. The guide to Python netrc explains the trade-offs.

Do not enable protocol debug output in production with real accounts. set_debuglevel(2) prints commands and responses and can expose sensitive information.

Structured listings with MLSD

When supported, mlsd() is preferable to parsing free-form LIST output.

with FTP_TLS(HOST, context=context, timeout=15) as ftps:
    ftps.login(USER, PASSWORD)
    ftps.prot_p()
    for name, facts in ftps.mlsd(
        "/incoming",
        facts=["type", "size", "modify"],
    ):
        print(name, facts)

A server is not required to return every requested fact. Use facts.get("size") and validate text before converting it.

NLST and LIST

nlst() returns names, while dir() and retrlines("LIST") produce server-dependent text. Do not extract size and date from fixed positions in LIST output; formats differ across Unix, Windows, and FTP products.

Downloading in blocks

from pathlib import Path

DESTINATION = Path("report.csv")
LIMIT = 50 * 1024 * 1024
received = 0

def write_block(block: bytes) -> None:
    global received
    received += len(block)
    if received > LIMIT:
        raise ValueError("file exceeds limit")
    output.write(block)

with DESTINATION.open("wb") as output:
    ftps.retrbinary(
        "RETR report.csv",
        write_block,
        blocksize=64 * 1024,
    )

Write to a temporary file and rename only after success. If the transfer fails, the final filename should not point to partial content.

Atomic downloads

from pathlib import Path
from tempfile import NamedTemporaryFile

final = Path("report.csv")

with NamedTemporaryFile(
    mode="wb",
    dir=final.parent,
    delete=False,
) as temporary:
    temp_path = Path(temporary.name)
    ftps.retrbinary("RETR report.csv", temporary.write)

temp_path.replace(final)

Add exception handling that deletes the temporary file on failure. The guide to Python tempfile shows safe patterns.

Integrity verification

FTP and FTPS do not prove that a file is the expected artifact. Compare size, a hash published through a trusted channel, or a digital signature.

import hashlib

with open("report.csv", "rb") as file:
    digest = hashlib.file_digest(file, "sha256").hexdigest()

if digest != EXPECTED_SHA256:
    raise ValueError("hash mismatch")

See Python hashlib. A hash downloaded from the same compromised server is not independent authentication.

Uploading files

from pathlib import Path

path = Path("output.zip")

if path.stat().st_size > 100 * 1024 * 1024:
    raise ValueError("file too large")

with path.open("rb") as file:
    ftps.storbinary(
        "STOR output.zip.part",
        file,
        blocksize=64 * 1024,
    )

ftps.rename("output.zip.part", "output.zip")

Uploading with a temporary remote name prevents consumers from seeing a file before the transfer completes. Confirm whether rename is atomic on the target server.

Text mode versus binary mode

Use retrbinary() and storbinary() for almost every file, including CSV and text, when exact bytes matter. retrlines() and storlines() apply line-oriented semantics and may alter line endings.

Filename encoding

Since Python 3.9, the default is UTF-8 according to RFC 2640. Older servers may use another encoding. Configure encoding only from the service contract; do not silently try arbitrary codecs.

Normalize Unicode when the local application needs consistent comparison, but preserve the remote name for remote operations. Never use a remote name directly as a local path without validation.

Protecting local paths

from pathlib import Path

ROOT = Path("downloads").resolve()

def safe_destination(remote_name: str) -> Path:
    name = Path(remote_name).name
    if name in {"", ".", ".."}:
        raise ValueError("invalid name")
    destination = (ROOT / name).resolve()
    if ROOT not in destination.parents:
        raise ValueError("path traversal")
    return destination

Reject path separators, reserved names, and control characters according to the local operating system.

Remote directories

cwd(), mkd(), rmd(), and pwd() manipulate remote directories. Avoid assembling commands from unvalidated paths. There is no universal escaping rule that makes every remote name safe.

Passive mode

Passive mode is enabled by default and generally works better through NAT and firewalls. set_pasv(False) selects active mode, which may require inbound connections to the client. Choose the mode with the network team and restrict server-side port ranges.

Resuming transfers

retrbinary() and storbinary() accept rest, usually a byte offset. The server may not support it.

offset = temp_path.stat().st_size
with temp_path.open("ab") as file:
    ftps.retrbinary(
        "RETR image.iso",
        file.write,
        rest=offset,
    )

Before resuming, verify that the remote file has not changed using size, modification data, and ideally a hash. Otherwise, the local file can combine different versions.

ftplib exceptions

Main exceptions include error_temp for temporary 4xx replies, error_perm for permanent 5xx replies, error_reply, and error_proto. all_errors also includes socket failures.

from ftplib import error_perm, error_temp

try:
    ftps.retrbinary("RETR file.dat", callback)
except error_temp as error:
    print("possibly temporary failure", error)
except error_perm as error:
    print("permission denied or missing file", error)

Do not retry permanent errors automatically. For temporary failures, use backoff, jitter, and an attempt limit.

Timeouts

Set timeout on the connection. Also control the total task duration and file size. A transfer that keeps receiving small chunks may run indefinitely without triggering a socket inactivity timeout.

Closing connections

Use a context manager. quit() sends a polite QUIT command but may fail if the connection is already broken. Context cleanup still closes resources. Never reuse an instance after quit() or close().

FTP, FTPS, and SFTP

FTP is the original unencrypted protocol. FTPS adds TLS to FTP and is supported by FTP_TLS. SFTP is a different protocol over SSH. Confusing these names causes connection errors and unsafe architecture decisions.

Test invalid login, invalid certificate, missing prot_p(), Unicode names, servers without MLSD, empty files, size limit, interrupted connection, unsupported resume, rename, temporary and permanent errors, and temporary-file cleanup.

Common mistakes

Frequent mistakes include using plain FTP with passwords, forgetting prot_p(), disabling TLS verification, parsing LIST with fixed columns, trusting remote names, writing directly to the final file, omitting size limits, logging credentials, retrying non-idempotent uploads, and confusing FTPS with SFTP.

Conclusion

ftplib supports FTP and FTPS integrations without external dependencies. Use FTP_TLS with a verified context and prot_p(), prefer mlsd(), transfer blocks into temporary files, verify size and integrity, protect paths, and classify errors.

Read the official ftplib documentation, FTP RFC 959, and FTPS RFC 4217. For new systems, consider protocols that are easier to secure and operate.

Share:

Facebook
WhatsApp
Twitter
LinkedIn

Article content

    Related articles

    Server rack representing an endpoint built with Python xmlrpc.server
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    xmlrpc.server: Build XML-RPC Servers

    Learn Python xmlrpc.server to build XML-RPC servers, register functions, restrict methods and paths, and avoid unsafe exposure.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    Server cables representing remote calls with Python xmlrpc.client
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python xmlrpc.client: Remote Calls

    Learn Python xmlrpc.client to call XML-RPC services, handle Fault and ProtocolError, use TLS, compatible types, and safe limits.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    Error code over binary data representing failures handled with Python urllib.error
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python urllib.error: Handle HTTP Failures

    Learn Python urllib.error to handle URLError, HTTPError, incomplete downloads, selective retries, and clearer network diagnostics.

    Ler mais

    Tempo de leitura: 5 minutos
    21/08/2026
    HTML keycaps representing HTML entities with Python html.entities
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    html.entities: Convert HTML Entities

    Learn Python html.entities to inspect HTML entities, convert names and code points, and avoid confusing decoding with sanitization.

    Ler mais

    Tempo de leitura: 6 minutos
    21/08/2026
    Folder with files representing MIME types identified with Python mimetypes
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    mimetypes: Detect MIME Types for Files

    Learn Python mimetypes to identify file types, validate uploads, and set safer HTTP Content-Type headers.

    Ler mais

    Tempo de leitura: 6 minutos
    20/08/2026
    HTML code on a screen representing parsing with Python html.parser
    Advanced Python
    Foto de perfil de Leandro Hirt da Academify

    Python html.parser: Parse HTML

    Learn Python html.parser to extract text, links, and metadata, process HTML incrementally, and avoid confusing parsing with sanitization.

    Ler mais

    Tempo de leitura: 5 minutos
    20/08/2026