Learning Goals
3 minBy the end of this lesson you can:
- Open an SFTP session over an existing SSH connection.
- Upload (
put) and download (get) files. - List, stat, and create remote directories.
- Transfer whole folder trees and verify transfers by size/hash.
Warm-Up · SFTP, Not FTP
5 minOld FTP sent files (and passwords!) in plaintext. SFTP is completely different: it's a file-transfer protocol that runs over SSH, so it's encrypted and uses the same key-based auth you set up last lesson. Same connection, new capability.
From a paramiko SSH client, client.open_sftp() gives you a file-transfer session. Its API feels like os/pathlib on the remote machine: put (upload), get (download), listdir, stat, mkdir. The discipline is the same as local files plus one extra: verify the transfer (compare sizes/hashes), because a dropped connection can leave a truncated file.
New Concept · The SFTP Session
14 minOpening SFTP
import paramiko, os client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect(os.environ["SSH_HOST"], username=os.environ["SSH_USER"], key_filename=os.environ["SSH_KEY"], timeout=15) sftp = client.open_sftp() # the file-transfer session # …transfer files… sftp.close() client.close()
SFTP rides the connection you already opened — no second login, same encryption and auth.
Upload & download
sftp.put("local/report.pdf", "/srv/reports/report.pdf") # upload sftp.get("/var/log/app.log", "downloads/app.log") # download
put(local, remote) uploads; get(remote, local) downloads. Paths are strings; remote paths are usually absolute. The remote parent folder must already exist.
Listing & inspecting
for name in sftp.listdir("/srv/reports"): # just names print(name) for attr in sftp.listdir_attr("/srv/reports"): # names + metadata print(attr.filename, attr.st_size, attr.st_mtime) info = sftp.stat("/srv/reports/report.pdf") # one file's metadata print(info.st_size)
This mirrors Lesson 6's local file inspection — st_size, st_mtime — but on the remote machine.
Making remote directories
def ensure_remote_dir(sftp, remote_dir: str) -> None: """mkdir -p for SFTP: create each path segment if missing.""" parts = remote_dir.strip("/").split("/") path = "" for part in parts: path += "/" + part try: sftp.stat(path) # already exists? except FileNotFoundError: sftp.mkdir(path)
SFTP's mkdir doesn't create parents, so this helper walks the path and makes each missing segment — the remote mkdir -p.
Verifying a transfer
from pathlib import Path def put_verified(sftp, local: str, remote: str) -> bool: sftp.put(local, remote) local_size = Path(local).stat().st_size remote_size = sftp.stat(remote).st_size if local_size != remote_size: raise IOError(f"size mismatch: {local_size} != {remote_size}") return True
A network drop mid-upload can leave a truncated file that looks present but is corrupt. The cheap check is comparing sizes; for true integrity, compare a hash of both ends (Lesson 38) — upload, then run sha256sum remotely via exec_command and compare to the local hash.
Worked Example · Off-Site Backup Uploader
12 minGoal: take last night's local backup (Lesson 38/39), upload it to a remote server into a dated folder, verify it, and prune old remote backups — the "1 off-site copy" of the 3-2-1 rule.
import os, logging from pathlib import Path from datetime import datetime from contextlib import contextmanager import paramiko logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("offsite") @contextmanager def sftp_session(): client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect(os.environ["SSH_HOST"], username=os.environ["SSH_USER"], key_filename=os.environ["SSH_KEY"], timeout=15) sftp = client.open_sftp() try: yield client, sftp finally: sftp.close(); client.close() def ensure_remote_dir(sftp, path: str): cur = "" for part in path.strip("/").split("/"): cur += "/" + part try: sftp.stat(cur) except FileNotFoundError: sftp.mkdir(cur) def upload_backup(local_file: str, remote_root: str, keep: int = 7) -> None: local = Path(local_file) stamp = datetime.now().strftime("%Y%m%d") remote_dir = f"{remote_root}/{stamp}" remote_path = f"{remote_dir}/{local.name}" with sftp_session() as (client, sftp): ensure_remote_dir(sftp, remote_dir) sftp.put(str(local), remote_path) # verify by size if sftp.stat(remote_path).st_size != local.stat().st_size: raise IOError("size mismatch — upload incomplete") log.info("uploaded %s → %s (%.1f KB)", local.name, remote_path, local.stat().st_size / 1000) # rotate remote backups: keep newest <keep> dated folders dated = sorted(d for d in sftp.listdir(remote_root)) for old in dated[:-keep]: _, _, err = (client.exec_command(f"rm -rf '{remote_root}/{old}'")) log.info("pruned remote %s", old) upload_backup("backups/db/app-20260528.db.gz", "/srv/offsite/db", keep=7)
INFO uploaded app-20260528.db.gz → /srv/offsite/db/20260528/app-20260528.db.gz (148.2 KB) INFO pruned remote 20260520
Read the code
This combines both halves of paramiko: SFTP put for the transfer, and exec_command (Lesson 41) to rm -rf old remote folders for rotation. The context manager closes both the SFTP and SSH sessions, ensure_remote_dir creates the dated path, and the size check refuses to trust a partial upload. Schedule this after your local DB backup (Lessons 36, 39) and you've automated the off-site copy that survives your building burning down — the most important backup of all.
Try It Yourself
13 minUse the same target as Lesson 41 (VM, Pi, or localhost SFTP).
Upload a file, then download it back under a new name, and confirm the contents match the original (compare bytes or hashes).
Print every file in a remote folder with its size and modified date (use listdir_attr). Find the largest remote file.
Hint
from datetime import datetime items = sftp.listdir_attr("/srv/data") for a in sorted(items, key=lambda x: x.st_size, reverse=True): when = datetime.fromtimestamp(a.st_mtime) print(f"{a.st_size:>10} {when:%Y-%m-%d} {a.filename}")
Write put_tree(sftp, local_dir, remote_dir) that walks a local folder (Lesson 6's rglob), recreates the structure remotely with ensure_remote_dir, and uploads every file. Verify each by size.
Hint
from pathlib import Path def put_tree(sftp, local_dir, remote_dir): base = Path(local_dir) for f in base.rglob("*"): if f.is_file(): rel = f.relative_to(base).as_posix() target = f"{remote_dir}/{rel}" ensure_remote_dir(sftp, target.rsplit("/", 1)[0]) sftp.put(str(f), target)
Mini-Challenge · Hash-Verified Transfer
8 minWrite put_checked(client, sftp, local, remote) that uploads a file, then computes the SHA-256 on both ends — locally in Python (Lesson 38) and remotely via sha256sum over exec_command (Lesson 41) — and confirms they match. True end-to-end integrity, not just size.
Show a sample solution
import hashlib from pathlib import Path def local_hash(path: str) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def put_checked(client, sftp, local: str, remote: str) -> bool: sftp.put(local, remote) want = local_hash(local) _, out, _ = client.exec_command(f"sha256sum '{remote}'") got = out.read().decode().split()[0] if want != got: raise IOError(f"hash mismatch: {want[:12]} != {got[:12]}") print("verified:", remote) return True
Non-negotiables: upload, local hash + remote sha256sum, compare, raise on mismatch.
Recap
3 minSFTP transfers files securely over the same SSH channel: client.open_sftp() gives a session with a pathlib-like API — put (upload), get (download), listdir/listdir_attr/stat (inspect), mkdir (no parents, so write your own mkdir -p). Always verify transfers — size at minimum, hashes for real integrity — because a dropped connection truncates silently. Combine SFTP transfers with Lesson 41's exec_command for full remote control: upload a deploy, run it, verify it. Off-site backup uploads are the killer app — the "1" in 3-2-1.
Vocabulary Card
- SFTP
- Secure file transfer running over an SSH connection.
- put / get
- Upload a local file to remote / download a remote file to local.
- listdir_attr
- Lists remote files with metadata (size, mtime), like a remote stat.
- transfer verification
- Confirming a file arrived intact via size or hash comparison.
Homework
4 minExtend your remote.py module with SFTP helpers: upload(local, remote) and download(remote, local) with size verification, ensure_remote_dir, and put_tree for whole folders. Build a small "offsite backup" script that uploads a local backup to a dated remote folder, verifies it, and rotates old ones. Test against a VM/Pi/localhost and note how you'd schedule it after your nightly local backup.
Sample · offsite backup flow
Local nightly (cron 02:00): dbbackup.py run → app-YYYYMMDD.db.gz
Offsite step (cron 02:30): offsite.py uploads that .gz to
/srv/offsite/db/YYYYMMDD/, verifies size,
prunes remote folders older than 7 days.
remote.py now offers:
upload(local, remote) — put + size check, raises on mismatch
download(remote, local)
ensure_remote_dir(path)— remote mkdir -p
put_tree(local, remote)— whole-folder upload, per-file verify
Why 02:30 after 02:00: the local dump must finish first; chaining
them (or '&&' in one cron line) guarantees order.Non-negotiables: verified upload/download, ensure_remote_dir, put_tree, an offsite script with rotation, scheduling note.