← Back to DevBytes

Implementing SFTP Protocol: From Theory to Practice

What is SFTP?

SFTP, or SSH File Transfer Protocol, is a network protocol that provides secure file access, file transfer, and file management over a reliable data stream. Despite the similarity in name, SFTP is not FTP run over SSH. It is an entirely different protocol designed from the ground up as an extension of the SSH-2 protocol. SFTP runs inside an SSH tunnel, leveraging SSH's authentication and encryption capabilities to protect both the data and the commands exchanged between client and server.

At its core, SFTP is a remote file system protocol. Unlike traditional FTP, which uses separate control and data connections, SFTP multiplexes everything through a single SSH connection. This means you can perform file operations—upload, download, rename, delete, list directories, change permissions—all over one encrypted channel, without the complexities of passive/active mode negotiations or firewall-unfriendly port ranges.

SFTP vs FTPS: A Critical Distinction

Developers often confuse SFTP with FTPS. The distinction is crucial:

SFTP's protocol operations are more granular and stateful than FTP's. For instance, SFTP supports atomic file renames, recursive directory listing with file attributes in a single response, and fine-grained permission management—capabilities that require awkward workarounds in FTP.

Why SFTP Matters for Modern Development

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

In contemporary software development, SFTP is indispensable across several domains:

Beyond security, SFTP offers reliability. The protocol handles network interruptions gracefully: most implementations support resume for interrupted transfers, and the SSH layer provides keep-alive mechanisms to detect stale connections. For long-running batch jobs, this is a significant advantage over HTTP-based file transfers that may require chunked uploads and manual checkpointing.

How the SFTP Protocol Works

The Layered Architecture

Understanding SFTP requires understanding its layered design. The stack looks like this:

SFTP Protocol Operations

The SFTP protocol uses a binary packet format. Each request packet contains a length field, a type code, a request ID (for matching responses), and operation-specific parameters. The core operations include:

Each request elicits a response: either a success confirmation with payload data, or an error status code with a message. The protocol is synchronous at the request level but supports pipelining—a client can issue multiple requests before receiving responses, allowing high-throughput transfers over high-latency links.

The Wire Format

SFTP packets are length-prefixed and type-tagged. A simplified view of a READ request on the wire:

+------+------+--------+----+------+------+
| Len  | Type | Req ID | Handle  | Offset| Len  |
| 4B   | 1B   | 4B     | 4B+str  | 8B    | 4B   |
+------+------+--------+---------+-------+------+

This binary efficiency means SFTP outperforms text-based protocols like FTP in parsing overhead. Libraries handle this encoding transparently, but understanding the structure helps when debugging raw packet dumps or implementing custom extensions.

Implementing an SFTP Client in Python

For practical implementation, we'll use the paramiko library, which provides a pure-Python SSH-2 implementation with full SFTP support. Paramiko is widely adopted, well-documented, and suitable for both interactive and automated use cases. We'll also explore asyncssh for high-concurrency scenarios.

Setting Up Your Environment

Install the required libraries:

pip install paramiko
pip install asyncssh  # optional, for async implementations

For testing, you'll need an SSH server with SFTP enabled. Most Linux distributions ship with OpenSSH's sftp-server subsystem enabled by default. You can verify this in /etc/ssh/sshd_config:

Subsystem sftp internal-sftp-server

Establishing an SFTP Session

The first step is establishing an SSH connection and opening an SFTP channel. Here's a complete, production-ready connection manager that handles both password and key-based authentication:

import paramiko
import os
import logging
from io import StringIO
from typing import Optional, Tuple

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class SFTPConnection:
    """Manages an SFTP connection with automatic reconnection support."""

    def __init__(
        self,
        host: str,
        port: int = 22,
        username: str = None,
        password: str = None,
        private_key_path: str = None,
        private_key_passphrase: str = None,
        timeout: float = 10.0,
    ):
        self.host = host
        self.port = port
        self.username = username
        self.password = password
        self.private_key_path = private_key_path
        self.private_key_passphrase = private_key_passphrase
        self.timeout = timeout
        self._transport: Optional[paramiko.Transport] = None
        self._sftp: Optional[paramiko.SFTPClient] = None

    def connect(self) -> paramiko.SFTPClient:
        """Establish the SSH connection and open the SFTP channel."""
        self._transport = paramiko.Transport((self.host, self.port))
        self._transport.start_client()

        # Determine authentication method
        if self.private_key_path:
            private_key = self._load_private_key()
            self._transport.auth_publickey(self.username, private_key)
            logger.info(f"Authenticated with key to {self.host}")
        elif self.password:
            self._transport.auth_password(self.username, self.password)
            logger.info(f"Authenticated with password to {self.host}")
        else:
            # Attempt password-less key agent authentication
            agent = paramiko.Agent()
            agent_keys = agent.get_keys()
            if agent_keys:
                for key in agent_keys:
                    try:
                        self._transport.auth_publickey(self.username, key)
                        logger.info(f"Authenticated via SSH agent to {self.host}")
                        break
                    except paramiko.AuthenticationException:
                        continue
                else:
                    raise paramiko.AuthenticationException("No acceptable key found in agent")
            else:
                raise ValueError("No authentication credentials provided")

        # Open SFTP channel
        self._sftp = paramiko.SFTPClient.from_transport(self._transport)
        logger.info(f"SFTP channel opened to {self.host}")
        return self._sftp

    def _load_private_key(self) -> paramiko.PKey:
        """Load a private key from file, handling different formats."""
        key_path = os.path.expanduser(self.private_key_path)
        if not os.path.isfile(key_path):
            raise FileNotFoundError(f"Private key not found: {key_path}")

        # Try different key formats
        key_classes = [
            paramiko.RSAKey,
            paramiko.ECDSACertainTypesKey,
            paramiko.Ed25519Key,
            paramiko.DSSKey,
        ]

        passphrase = self.private_key_passphrase

        for key_class in key_classes:
            try:
                return key_class.from_private_key_file(key_path, passphrase)
            except paramiko.ssh_exception.PasswordRequiredException:
                raise  # Re-raise if passphrase is required but not provided
            except Exception:
                continue

        # If none of the specific types worked, try the generic method
        try:
            return paramiko.PKey.from_private_key_file(key_path, passphrase)
        except paramiko.ssh_exception.PasswordRequiredException:
            raise
        except Exception as e:
            raise ValueError(f"Could not load private key from {key_path}: {e}")

    @property
    def sftp(self) -> paramiko.SFTPClient:
        """Get the active SFTP client, connecting if necessary."""
        if self._sftp is None or self._sftp.sock is None:
            return self.connect()
        return self._sftp

    def close(self):
        """Gracefully close the SFTP channel and SSH connection."""
        if self._sftp:
            try:
                self._sftp.close()
            except Exception:
                pass
            self._sftp = None
        if self._transport:
            try:
                self._transport.close()
            except Exception:
                pass
            self._transport = None
        logger.info(f"Connection to {self.host} closed")

    def __enter__(self):
        return self.connect()

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False

File Upload and Download Operations

With the connection established, here are robust implementations for common transfer operations. Notice the use of SFTPClient.put and SFTPClient.get which handle chunked transfers internally, along with callback-based progress reporting:

import os
import sys
from contextlib import contextmanager
from typing import Callable, Optional


class SFTPTransfer:
    """High-level SFTP transfer operations with progress tracking."""

    def __init__(self, sftp_client: paramiko.SFTPClient):
        self.sftp = sftp_client

    def upload_file(
        self,
        local_path: str,
        remote_path: str,
        callback: Optional[Callable[[int, int], None]] = None,
        overwrite: bool = True,
    ) -> None:
        """
        Upload a single file to the remote server.

        Args:
            local_path: Absolute or relative path to the local file
            remote_path: Destination path on the remote server
            callback: Progress function receiving (bytes_transferred, total_bytes)
            overwrite: Whether to overwrite existing remote file
        """
        if not os.path.isfile(local_path):
            raise FileNotFoundError(f"Local file not found: {local_path}")

        file_size = os.path.getsize(local_path)
        remote_dir = os.path.dirname(remote_path)

        # Ensure remote directory exists
        self._ensure_remote_directory(remote_dir)

        # Define internal callback to track progress
        transferred_bytes = [0]

        def internal_callback(chunk_transferred, _):
            transferred_bytes[0] += chunk_transferred
            if callback:
                callback(transferred_bytes[0], file_size)

        try:
            self.sftp.put(
                localpath=local_path,
                remotepath=remote_path,
                callback=internal_callback,
                confirm=True,  # Wait for server confirmation of each chunk
            )
            print(f"\nUpload complete: {local_path} -> {remote_path}")
        except Exception as e:
            # Attempt cleanup of partial file
            try:
                self.sftp.remove(remote_path)
            except Exception:
                pass
            raise IOError(f"Upload failed: {e}")

    def download_file(
        self,
        remote_path: str,
        local_path: str,
        callback: Optional[Callable[[int, int], None]] = None,
        resume: bool = False,
    ) -> None:
        """
        Download a file from the remote server with optional resume support.

        Args:
            remote_path: Path to the remote file
            local_path: Local destination path
            callback: Progress function receiving (bytes_transferred, total_bytes)
            resume: If True and local file exists, attempt to resume download
        """
        # Ensure local directory exists
        local_dir = os.path.dirname(local_path)
        if local_dir:
            os.makedirs(local_dir, exist_ok=True)

        # Get remote file attributes
        try:
            remote_stat = self.sftp.stat(remote_path)
            remote_size = remote_stat.st_size
        except IOError as e:
            raise IOError(f"Cannot stat remote file '{remote_path}': {e}")

        # Determine resume offset
        local_offset = 0
        if resume and os.path.exists(local_path):
            local_offset = os.path.getsize(local_path)
            if local_offset >= remote_size:
                print("Local file is already complete.")
                return
            print(f"Resuming download from byte {local_offset}")

        transferred_bytes = [local_offset]

        def internal_callback(chunk_transferred, _):
            transferred_bytes[0] += chunk_transferred
            if callback:
                callback(transferred_bytes[0], remote_size)

        try:
            self.sftp.get(
                remotepath=remote_path,
                localpath=local_path,
                callback=internal_callback,
            )
            print(f"\nDownload complete: {remote_path} -> {local_path}")
        except Exception as e:
            # Keep partial file for resume
            raise IOError(f"Download failed: {e}")

    def upload_directory(
        self,
        local_dir: str,
        remote_dir: str,
        recursive: bool = True,
    ) -> int:
        """
        Recursively upload a directory tree, preserving structure.

        Returns the count of files transferred.
        """
        if not os.path.isdir(local_dir):
            raise NotADirectoryError(f"Local path is not a directory: {local_dir}")

        file_count = 0

        for dirpath, _, filenames in os.walk(local_dir):
            # Compute relative path for remote side
            rel_path = os.path.relpath(dirpath, local_dir)
            if rel_path == ".":
                current_remote_dir = remote_dir
            else:
                # Normalize path separators for the remote system
                current_remote_dir = os.path.join(remote_dir, rel_path).replace("\\", "/")

            # Ensure remote subdirectory exists
            self._ensure_remote_directory(current_remote_dir)

            for filename in filenames:
                local_file = os.path.join(dirpath, filename)
                remote_file = os.path.join(current_remote_dir, filename).replace("\\", "/")
                self.upload_file(local_file, remote_file)
                file_count += 1

            if not recursive:
                break

        return file_count

    def _ensure_remote_directory(self, remote_path: str) -> None:
        """Create a remote directory path if it doesn't exist."""
        if remote_path in ("", ".", "/"):
            return

        try:
            self.sftp.stat(remote_path)
            # Path exists, verify it's a directory
            return
        except IOError:
            pass  # Path doesn't exist

        # Recursively create parent directories
        parent = os.path.dirname(remote_path)
        if parent and parent != remote_path:
            self._ensure_remote_directory(parent)

        try:
            self.sftp.mkdir(remote_path)
            print(f"Created remote directory: {remote_path}")
        except IOError as e:
            # Check if it was created by a concurrent operation
            try:
                self.sftp.stat(remote_path)
                return
            except IOError:
                raise IOError(f"Cannot create remote directory '{remote_path}': {e}")

Remote File Listing and Metadata

Listing remote directories with file attributes is essential for building file browsers, sync tools, or audit scripts. The SFTPClient.listdir_attr method returns a list of SFTPAttributes objects, each containing the full stat information:

import time
import stat as stat_module
from datetime import datetime
from typing import List, Dict


class SFTPBrowser:
    """Remote file system browsing utilities."""

    def __init__(self, sftp_client: paramiko.SFTPClient):
        self.sftp = sftp_client

    def list_directory(self, remote_path: str = ".") -> List[Dict]:
        """
        List a remote directory with full metadata.

        Returns a list of dictionaries with keys:
            name, size, mode, atime, mtime, owner, group, is_dir
        """
        entries = []

        try:
            for attr in self.sftp.listdir_attr(remote_path):
                entry = {
                    "name": attr.filename,
                    "size": attr.st_size if attr.st_size is not None else 0,
                    "mode": attr.st_mode,
                    "is_dir": stat_module.S_ISDIR(attr.st_mode) if attr.st_mode else False,
                    "is_link": stat_module.S_ISLNK(attr.st_mode) if attr.st_mode else False,
                    "atime": datetime.fromtimestamp(attr.st_atime) if attr.st_atime else None,
                    "mtime": datetime.fromtimestamp(attr.st_mtime) if attr.st_mtime else None,
                    "owner": attr.st_uid,
                    "group": attr.st_gid,
                }
                entries.append(entry)
        except IOError as e:
            raise IOError(f"Failed to list directory '{remote_path}': {e}")

        return entries

    def list_recursive(self, remote_path: str = ".", max_depth: int = 5) -> Dict[str, List[Dict]]:
        """
        Recursively list a remote directory tree up to max_depth levels.

        Returns a dictionary mapping remote paths to their directory listings.
        """
        tree = {}
        self._walk_remote(remote_path, tree, max_depth, current_depth=0)
        return tree

    def _walk_remote(self, path: str, tree: Dict, max_depth: int, current_depth: int):
        if current_depth > max_depth:
            return

        entries = self.list_directory(path)
        tree[path] = entries

        for entry in entries:
            if entry["is_dir"] and not entry["is_link"]:
                child_path = f"{path}/{entry['name']}".replace("//", "/")
                self._walk_remote(child_path, tree, max_depth, current_depth + 1)

    def search_files(self, remote_dir: str, pattern: str) -> List[str]:
        """
        Search for files matching a glob-like pattern in a remote directory.

        This is a simple substring-based search; for full globbing,
        retrieve the listing and filter with fnmatch locally.
        """
        import fnmatch

        matches = []
        try:
            entries = self.sftp.listdir(remote_dir)
            for filename in entries:
                if fnmatch.fnmatch(filename, pattern):
                    matches.append(f"{remote_dir}/{filename}".replace("//", "/"))
        except IOError as e:
            raise IOError(f"Search failed in '{remote_dir}': {e}")

        return matches

    def get_file_info(self, remote_path: str) -> Dict:
        """
        Retrieve detailed stat information for a single remote file or directory.
        """
        try:
            attr = self.sftp.stat(remote_path)
        except IOError as e:
            raise IOError(f"Cannot stat '{remote_path}': {e}")

        return {
            "path": remote_path,
            "size": attr.st_size or 0,
            "mode": attr.st_mode,
            "permissions": stat_module.S_IMODE(attr.st_mode) if attr.st_mode else 0,
            "is_dir": stat_module.S_ISDIR(attr.st_mode) if attr.st_mode else False,
            "atime": datetime.fromtimestamp(attr.st_atime) if attr.st_atime else None,
            "mtime": datetime.fromtimestamp(attr.st_mtime) if attr.st_mtime else None,
            "uid": attr.st_uid,
            "gid": attr.st_gid,
        }

Complete Integration Example

Here's how all the components work together in a real-world script that syncs a local directory to a remote SFTP server, preserving timestamps and handling errors gracefully:

#!/usr/bin/env python3
"""
sftp_sync.py - Bidirectional directory sync over SFTP.

Usage:
    python sftp_sync.py push --host example.com --user deploy \
        --key ~/.ssh/id_rsa --local ./build/ --remote /var/www/app/

    python sftp_sync.py pull --host example.com --user deploy \
        --key ~/.ssh/id_rsa --remote /var/www/logs/ --local ./logs/
"""

import argparse
import os
import sys
import logging
from datetime import datetime

# Assuming the classes above are in a module called sftp_client
# For this example, we inline the essential logic

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("sftp_sync")


def sync_push(connection, local_dir: str, remote_dir: str):
    """Push local files to remote, uploading only changed files."""
    sftp = connection.sftp
    transfer = SFTPTransfer(sftp)

    for dirpath, _, filenames in os.walk(local_dir):
        rel_path = os.path.relpath(dirpath, local_dir)
        if rel_path == ".":
            current_remote = remote_dir
        else:
            current_remote = f"{remote_dir}/{rel_path}".replace("\\", "/")

        for filename in filenames:
            local_file = os.path.join(dirpath, filename)
            remote_file = f"{current_remote}/{filename}".replace("//", "/")

            local_mtime = os.path.getmtime(local_file)
            local_size = os.path.getsize(local_file)

            # Check if remote file needs updating
            try:
                remote_stat = sftp.stat(remote_file)
                # Compare modification times and sizes
                if (abs(remote_stat.st_mtime - local_mtime) < 2 and
                        remote_stat.st_size == local_size):
                    logger.debug(f"Skipping unchanged: {filename}")
                    continue
            except IOError:
                pass  # Remote file doesn't exist, upload needed

            logger.info(f"Uploading: {filename} ({local_size} bytes)")
            transfer.upload_file(local_file, remote_file)

            # Preserve modification timestamp
            try:
                remote_stat = sftp.stat(remote_file)
                attr = paramiko.SFTPAttributes()
                attr.st_mtime = local_mtime
                attr.st_atime = local_mtime
                attr.st_mode = remote_stat.st_mode
                sftp.utime(remote_file, attr)
            except Exception as e:
                logger.warning(f"Could not preserve timestamp on {filename}: {e}")


def sync_pull(connection, remote_dir: str, local_dir: str):
    """Pull remote files to local, downloading only changed files."""
    sftp = connection.sftp
    transfer = SFTPTransfer(sftp)
    browser = SFTPBrowser(sftp)

    remote_tree = browser.list_recursive(remote_dir, max_depth=10)

    for remote_path, entries in remote_tree.items():
        rel_path = os.path.relpath(remote_path, remote_dir)
        if rel_path == ".":
            current_local = local_dir
        else:
            current_local = os.path.join(local_dir, rel_path)

        os.makedirs(current_local, exist_ok=True)

        for entry in entries:
            if entry["is_dir"]:
                continue

            remote_file = f"{remote_path}/{entry['name']}".replace("//", "/")
            local_file = os.path.join(current_local, entry["name"])

            # Check if download is needed
            if os.path.exists(local_file):
                local_mtime = os.path.getmtime(local_file)
                remote_mtime = entry["mtime"]
                if remote_mtime:
                    remote_mtime_ts = remote_mtime.timestamp()
                    if abs(local_mtime - remote_mtime_ts) < 2:
                        logger.debug(f"Skipping unchanged: {entry['name']}")
                        continue

            logger.info(f"Downloading: {entry['name']} ({entry['size']} bytes)")
            transfer.download_file(remote_file, local_file)

            # Preserve remote modification timestamp locally
            if entry["mtime"]:
                mtime_ts = entry["mtime"].timestamp()
                os.utime(local_file, (mtime_ts, mtime_ts))


def main():
    parser = argparse.ArgumentParser(description="SFTP Directory Sync Tool")
    parser.add_argument("direction", choices=["push", "pull"])
    parser.add_argument("--host", required=True)
    parser.add_argument("--port", type=int, default=22)
    parser.add_argument("--user", required=True)
    parser.add_argument("--password")
    parser.add_argument("--key")
    parser.add_argument("--key-passphrase")
    parser.add_argument("--local", required=True)
    parser.add_argument("--remote", required=True)
    args = parser.parse_args()

    connection = SFTPConnection(
        host=args.host,
        port=args.port,
        username=args.user,
        password=args.password,
        private_key_path=args.key,
        private_key_passphrase=args.key_passphrase,
    )

    try:
        connection.connect()
        start_time = datetime.now()
        logger.info(f"Starting {args.direction} sync...")

        if args.direction == "push":
            sync_push(connection, args.local, args.remote)
        else:
            sync_pull(connection, args.remote, args.local)

        elapsed = (datetime.now() - start_time).total_seconds()
        logger.info(f"Sync complete in {elapsed:.2f} seconds")
    except Exception as e:
        logger.error(f"Sync failed: {e}")
        sys.exit(1)
    finally:
        connection.close()


if __name__ == "__main__":
    main()

Asynchronous SFTP with AsyncSSH

For high-concurrency applications—such as monitoring hundreds of remote servers or handling many simultaneous transfers—an asynchronous approach is essential. The asyncssh library provides an asyncio-native SFTP implementation that can handle thousands of connections with minimal thread overhead:

import asyncio
import asyncssh
from typing import List, Dict


async def fetch_remote_inventory(hosts: List[Dict]) -> List[Dict]:
    """
    Connect to multiple SFTP servers concurrently and fetch file inventories.

    Each host dict should contain: host, username, password or client_keys.
    """
    results = []

    async def process_one(host_info: Dict):
        hostname = host_info["host"]
        try:
            async with asyncssh.connect(
                host=hostname,
                username=host_info.get("username"),
                password=host_info.get("password"),
                client_keys=host_info.get("client_keys", ()),
                known_hosts=None,  # Disable for demo; use proper known_hosts in production
            ) as conn:
                async with conn.start_sftp_client() as sftp:
                    # List root directory
                    entries = await sftp.listdir(".")
                    file_info = []
                    for entry in entries:
                        stat = await sftp.stat(entry)
                        file_info.append({
                            "name": entry,
                            "size": stat.st_size,
                            "mtime": stat.st_mtime,
                        })
                    return {"host": hostname, "status": "ok", "files": file_info}
        except Exception as e:
            return {"host": hostname, "status": "error", "message": str(e)}

    tasks = [process_one(host) for host in hosts]
    results = await asyncio.gather(*tasks)
    return list(results)


async def async_upload_example():
    """Demonstrate concurrent uploads to multiple servers."""
    async with asyncssh.connect(
        host="sftp.example.com",
        username="deploy",
        client_keys=["~/.ssh/id_rsa"],
    ) as conn:
        async with conn.start_sftp_client() as sftp:
            # Upload a file asynchronously
            local_path = "./deploy.tar.gz"
            remote_path = "/var/www/releases/deploy.tar.gz"

            async with open(local_path, "rb") as local_file:
                async with sftp.open(remote_path, "wb") as remote_file:
                    while True:
                        chunk = local_file.read(32768)
                        if not chunk:
                            break
                        await remote_file.write(chunk)

            print(f"Async upload complete: {remote_path}")


# Run the example
if __name__ == "__main__":
    hosts = [
        {"host": "server1.example.com", "username": "ops"},
        {"host": "server2.example.com", "username": "ops"},
    ]
    results = asyncio.run(fetch_remote_inventory(hosts))
    for result in results:
        print(f"{result['host']}: {result['status']}")

Best Practices for Production SFTP Implementations

1. Connection Management

Always use connection pooling for applications that make frequent SFTP calls. Opening a new SSH connection for each transfer is expensive due to the key exchange handshake. Maintain a pool of pre-authenticated connections and implement health checks to discard stale ones. Paramiko's Transport objects can be kept alive by sending SSH keep-alive messages:

transport.send_ignore()  # Sends an SSH_IGNORE packet to keep the connection alive

Schedule these every 30-60 seconds for idle connections, and implement an exponential backoff reconnection strategy for transient failures.

2. Authentication Security

3. Error Handling and Resilience

SFTP operations can fail for numerous reasons: network interruptions, disk full conditions on the server, permission changes, or server-side resource limits. Implement these patterns:

4. Performance Optimization

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles