ZetCode

Python FTP

last modified October 30, 2026 Python 3.15

In this article we show how to work with FTP in Python using the ftplib module. We connect to an FTP server, list directories, and download and upload files.

FTP

File Transfer Protocol (FTP) is a standard network protocol used for transferring computer files between a client and server on a computer network. Clients and servers communicate with a set of FTP commands, such as DELE, RETR, or CWD. The commands are sent over a control connection; file data and directory listings are transferred over separate data connections.

Many servers provide FTP services with anonymous access, which allows downloading files without an account. Plain FTP sends passwords and data unencrypted. For sensitive data, FTP over TLS (the ftplib.FTP_TLS class) or SFTP should be used instead.

Python ftplib

Python ftplib is a module that implements the client side of the FTP protocol. It contains the FTP client class and some helper functions. The FTP class is a context manager; at the end of the with block, it sends the QUIT command and closes the connection.

The ftplib.FTP constructor connects to the given host on the standard port 21. To use a different port, we create the object without a host and call the connect method.

Local FTP server

Public anonymous FTP servers have become rare; for instance, Debian shut down its FTP service in 2017. To have a predictable test environment, we run a local FTP server written in Python with the pyftpdlib library.

$ pip install pyftpdlib

Running the server

The server has a user account and anonymous access.

ftp_server.py
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

authorizer = DummyAuthorizer()
authorizer.add_user('user7', 's$cret', 'ftproot/home/user7', perm='elradfmw')
authorizer.add_anonymous('ftproot/pub')

handler = FTPHandler
handler.authorizer = authorizer
handler.banner = 'Welcome to the ZetCode test FTP server'

server = FTPServer(('127.0.0.1', 2121), handler)
server.serve_forever()

The user7 user with the s$cret password has full permissions in the ftproot/home/user7 directory. The elradfmw string stands for change directory, list, retrieve, append, delete, rename, make directory, and write. Anonymous users can only list and download files from the ftproot/pub directory. The server listens on port 2121 of the local machine; ports below 1024 require root privileges. We start the server in the background; it stays running for the rest of the examples.

$ python ftp_server.py &
[I 2026-10-30 21:30:43] concurrency model: async
[I 2026-10-30 21:30:43] masquerade (NAT) address: None
[I 2026-10-30 21:30:43] passive ports: None
[I 2026-10-30 21:30:43] >>> starting FTP server on 127.0.0.1:2121, pid=169146 <<<

Welcome message

The getwelcome method returns the welcome message sent by the server in reply to the initial connection. This message may contain some helpful information for the user.

welcome.py
import ftplib

with ftplib.FTP() as ftp:
    print(ftp.connect('localhost', 2121))
    print(ftp.getwelcome())

The connect method also returns the welcome message. FTP replies start with a three-digit status code; 220 means that the service is ready.

$ python welcome.py
220 Welcome to the ZetCode test FTP server
220 Welcome to the ZetCode test FTP server

Directory listing

The dir method produces a directory listing.

listing.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        files = []
        ftp.dir(files.append)

        for line in files:
            print(line)

    except ftplib.all_errors as e:
        print('FTP error:', e)

When the login method has no parameters, we log in to the anonymous account. The dir method sends the LIST command and passes each line of the listing to the callback; here we collect the lines in a list. The format of the listing is not standardized; most servers imitate the output of the Unix ls -l command.

$ python listing.py
-rw-rw-r--   1 user     user           94 Oct 30 20:28 README
drwxrwxr-x   2 user     user         4096 Oct 30 20:28 docs
-rw-rw-r--   1 user     user           74 Oct 30 20:29 words.txt.gz

Machine-readable listing with MLSD

The mlsd method uses the MLSD command, which returns a listing in a standardized, machine-readable format.

mlsd.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        for name, facts in ftp.mlsd('/', facts=['type', 'size', 'modify']):
            size = facts.get('size', '-')
            print(f"{facts['type']:<5} {size:>6} {facts['modify']} {name}")

    except ftplib.all_errors as e:
        print('FTP error:', e)

The method yields tuples of a file name and a dictionary of facts. With the facts parameter, we ask for the type, the size, and the modification time, which is given in UTC in the YYYYMMDDHHMMSS format. Unlike the output of LIST, these values are easy to parse.

$ python mlsd.py
file      94 20261030202858 README
file      74 20261030202910 words.txt.gz
dir     4096 20261030202858 docs

FTP commands

The FTP client sends commands to the FTP server, such as PWD or RETR. The ftplib module contains methods that wrap these commands. Other commands are sent with the sendcmd or voidcmd methods.

ftp_commands.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        resp = ftp.sendcmd('PWD')
        print(resp)
        print(ftplib.parse257(resp))

        print(ftp.pwd())

        print(ftp.sendcmd('SYST'))
        print(ftp.voidcmd('NOOP'))

    except ftplib.all_errors as e:
        print('FTP error:', e)

We send the PWD command directly with sendcmd, which returns the reply string. The parse257 helper function extracts the directory from a 257 reply. The pwd method does both steps for us. The SYST command returns the type of the server's operating system. The voidcmd method raises an exception if the reply is not a success code; the NOOP command does nothing and is used to keep the connection alive.

$ python ftp_commands.py
257 "/" is the current directory.
/
/
215 UNIX Type: L8
200 I successfully did nothing'.

Changing directory

The cwd method changes the current working directory on the server.

change_directory.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        print(ftp.pwd())

        ftp.cwd('docs')
        print(ftp.pwd())
        print(ftp.nlst())

        ftp.cwd('..')
        print(ftp.pwd())

        ftp.cwd('photos')

    except ftplib.all_errors as e:
        print('FTP error:', e)

We change to the docs directory, list the file names with nlst, and go back to the parent directory. Changing to a directory that does not exist raises ftplib.error_perm, which is one of the exceptions in the ftplib.all_errors tuple.

$ python change_directory.py
/
/docs
['users.csv', 'words.txt']
/
FTP error: 550 No such file or directory.

Getting size of a file

The SIZE command and its size method equivalent return the size of a file in bytes. In ASCII mode, the size of a text file depends on the line ending conversions, so many servers refuse the command unless the binary mode is set.

file_size.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        try:
            print(ftp.size('README'))
        except ftplib.error_perm as e:
            print(e)

        ftp.voidcmd('TYPE I')

        print(ftp.size('README'))
        print(ftp.size('words.txt.gz'))

    except ftplib.all_errors as e:
        print('FTP error:', e)

The server refuses the SIZE command in the default ASCII mode. We switch to the binary mode with the TYPE I command; TYPE A would switch back to ASCII. Then we get the sizes of a text file and a binary file.

$ python file_size.py
550 SIZE not allowed in ASCII mode.
94
74

Downloading a text file

To download a text file, we use the RETR FTP command with the retrlines method.

download_text_file.py
import ftplib
import os

file_orig = 'README'
file_copy = 'README.txt'

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        with open(file_copy, 'w') as f:
            res = ftp.retrlines(f'RETR {file_orig}',
                                lambda line: f.write(line + '\n'))

        print(res)

    except ftplib.all_errors as e:
        print('FTP error:', e)

        if os.path.isfile(file_copy):
            os.remove(file_copy)

The retrlines method transfers the file in ASCII mode and calls the callback for each line. The lines are passed without the line endings, so we add the newline character when writing them to the local file. The method returns the final reply of the server. In case of an error, we delete the incomplete local file.

$ python download_text_file.py
226 Transfer complete.
$ cat README.txt
ZetCode test FTP server

This directory contains sample files for the
Python ftplib tutorial.

Downloading a binary file

Binary files are downloaded with the retrbinary method.

download_binary_file.py
import ftplib
import gzip

file_orig = 'words.txt.gz'

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login()

        with open(file_orig, 'wb') as f:
            res = ftp.retrbinary(f'RETR {file_orig}', f.write)

        print(res)

    except ftplib.all_errors as e:
        print('FTP error:', e)

with gzip.open(file_orig, 'rt') as f:
    lines = f.readlines()

print(len(lines), 'lines')
print(lines[:5])

The retrbinary method transfers the file in binary mode and passes the data in blocks to the callback; we write them directly to a file opened in binary mode. Then we decompress the downloaded gzip file to check its contents.

$ python download_binary_file.py
226 Transfer complete.
1000 lines
['sky\n', 'blue\n', 'falcon\n', 'owl\n', 'cloud\n']

Login error

The login method logs in with a user name and a password. Wrong credentials raise ftplib.error_perm.

login_error.py
import ftplib

with ftplib.FTP() as ftp:

    ftp.connect('localhost', 2121)

    try:
        ftp.login('user7', 'secret')
    except ftplib.error_perm as e:
        print('Login failed:', e)

    print(ftp.login('user7', 's$cret'))

The first attempt fails with the 530 reply code. The connection stays open, so we can try again with the correct password.

$ python login_error.py
Login failed: 530 Authentication failed.
230 Login successful.

Creating directories

A directory is created with the mkd method. This operation requires a user account with sufficient privileges; it is not available with the anonymous account of our server.

create_directory.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login('user7', 's$cret')

        print(ftp.mkd('newdir'))
        print(ftp.mkd('newdir/sub'))

        ftp.retrlines('LIST')

    except ftplib.all_errors as e:
        print('FTP error:', e)

We log in as user7 and create a directory with a subdirectory. The mkd method returns the path of the new directory. The retrlines method called with the LIST command and no callback prints the listing to the console.

$ python create_directory.py
/newdir
/newdir/sub
drwxrwxr-x   3 user     user         4096 Oct 30 20:30 newdir

Uploading files

Files are uploaded with the STOR command. The storlines method uploads text files in ASCII mode, the storbinary method uploads files in binary mode.

upload_files.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login('user7', 's$cret')

        with open('notes.txt', 'rb') as f:
            print(ftp.storlines('STOR notes.txt', f))

        with open('words.txt.gz', 'rb') as f:
            print(ftp.storbinary('STOR newdir/words.txt.gz', f))

        for name, facts in ftp.mlsd('newdir', facts=['type', 'size']):
            print(name, facts)

    except ftplib.all_errors as e:
        print('FTP error:', e)

Both methods read the data from a file object opened in binary mode. We upload a local text file to the home directory of the user and the downloaded gzip file to the newdir directory. Then we list the directory with mlsd.

$ python upload_files.py
226 Transfer complete.
226 Transfer complete.
words.txt.gz {'size': '74', 'type': 'file'}
sub {'size': '4096', 'type': 'dir'}

Renaming and deleting

The rename method renames a file, the delete method deletes a file, and the rmd method removes an empty directory.

rename_delete.py
import ftplib

with ftplib.FTP() as ftp:

    try:
        ftp.connect('localhost', 2121)
        ftp.login('user7', 's$cret')

        print(ftp.rename('notes.txt', 'falcon.txt'))
        print(ftp.delete('newdir/words.txt.gz'))
        print(ftp.rmd('newdir/sub'))
        print(ftp.rmd('newdir'))

        print(ftp.nlst())

    except ftplib.all_errors as e:
        print('FTP error:', e)

We rename the uploaded text file, delete the binary file, and remove the two directories; the subdirectory has to be removed first. Finally, we list the names in the home directory.

$ python rename_delete.py
250 Renaming ok.
250 File removed.
250 Directory removed.
250 Directory removed.
['falcon.txt']

Source

ftplib - FTP protocol client - Python documentation

In this article we have worked with Python ftplib.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.