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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
List all Python tutorials.