paramiko
Paramiko is a Python implementation of the SSHv2 protocol, giving you both a client and a server in pure Python instead of shelling out to the ssh binary. You get SSHClient for running remote commands, an SFTP client for file transfer, Transport and Channel for raw port forwarding and tunnels, and key classes for RSA, ECDSA and Ed25519. The cryptographic work is delegated to the cryptography package, so it is only pure-Python in the protocol sense. The maintainers say plainly in the README that most people running remote commands or copying files should use Fabric, which wraps Paramiko, and that direct Paramiko use is for people who need low-level primitives or an in-process sshd.
Paramiko is still the reference SSH implementation for Python and the only realistic choice if you need the server side or raw channels. For ordinary remote commands and file copies, take the maintainers' own advice and use Fabric instead.
Use it if
- You need SSH from inside a Python process where subprocess-ing to the ssh binary is not acceptable: containers without an ssh client, Windows hosts, or sandboxes that block process spawning
- You need to speak SFTP programmatically with stat, chmod, rename, and streaming file handles rather than driving sftp or scp as a child process
- You need to write an SSH server in Python: Paramiko is one of the very few libraries that implements the server side of SSHv2 including auth callbacks and channel handling
- You need low-level control over channels, direct-tcpip tunnels, agent forwarding, or per-connection algorithm negotiation via disabled_algorithms
- You just want to run a remote command or copy a file: Fabric is written by the same maintainer, sits on top of Paramiko, and handles connection reuse, sudo, and config parsing that you would otherwise write yourself
- You need speed on large transfers: Paramiko does its framing and buffering in Python, and SFTP throughput is well below OpenSSH's scp or rsync on the same link, which is a recurring theme in the issue tracker
- You need asyncio: the API is blocking and thread-based, so every call ties up a thread; asyncssh is the native async alternative
- You need SSH features Paramiko does not implement: GSSAPI/Kerberos was removed entirely in 5.0.0, DSA keys were removed in 4.0.0, and SHA-1 based key exchange and RSA signatures are gone as of 5.0.0, so old appliances and legacy jump hosts can simply fail to connect
- You want a quiet dependency: it pulls in cryptography, bcrypt, pynacl, and invoke, and 890 open issues sit against a project that releases a few times a year
Setup reality
pip install paramiko is usually fine because cryptography, bcrypt, and pynacl all ship wheels for common platforms, but on musl images or odd architectures you are compiling Rust and C, and that is where CI breaks. Since 4.0.0 invoke is a hard dependency rather than an extra, so the install is heavier than people expect. The real friction is behavioral: SSHClient rejects unknown host keys by default, so first-run scripts fail until you call load_system_host_keys() or set a policy; the widely copy-pasted AutoAddPolicy silently disables host key checking. Upgrading from 3.x to 5.x can break connections to older servers because SHA-1 kex, SHA-1 RSA signatures, DSA keys, and GSSAPI have all been removed.
Patterns
Connect and run a commandrun-remote-command
import paramiko
client = paramiko.SSHClient()
client.load_system_host_keys() # trust ~/.ssh/known_hosts
client.connect("host.example.com", username="deploy", timeout=10)
stdin, stdout, stderr = client.exec_command("uname -a")
print(stdout.read().decode())
client.close()Without load_system_host_keys() or an explicit policy, connect() raises SSHException on any host it has never seen. That is the correct default, not a bug.
Get the exit code and stderr of a commandcheck-exit-status
stdin, stdout, stderr = client.exec_command("exit 3; echo nope")
out = stdout.read().decode()
err = stderr.read().decode()
code = stdout.channel.recv_exit_status() # read streams FIRST
if code != 0:
raise RuntimeError(f"remote failed ({code}): {err}")recv_exit_status() blocks until the command finishes. Call it before draining stdout and a command that writes more than the channel window (about 2 MB) deadlocks forever.
Authenticate with a private key filekey-file-auth
from paramiko import PKey, SSHClient
key = PKey.from_path("/home/me/.ssh/id_ed25519", password="passphrase-or-None")
client = SSHClient()
client.load_system_host_keys()
client.connect("host.example.com", username="deploy", pkey=key, look_for_keys=False)PKey.from_path sniffs the key type for you. In Paramiko 5 the keyword is password; it was called passphrase in 3.x, so this line breaks silently on upgrade if you passed it by name.
Pin host keys instead of blindly accepting themverify-host-keys
import paramiko
client = paramiko.SSHClient()
client.load_host_keys("/etc/myapp/known_hosts") # writable store
client.set_missing_host_key_policy(paramiko.RejectPolicy())
# AutoAddPolicy() accepts ANY key on first sight and writes it down.
# That is trust-on-first-use with no user prompt: fine for a lab, not for prod.load_host_keys() marks the file writable so accepted keys are saved; load_system_host_keys() is read-only. Mixing them up means new keys are never persisted.
Transfer files over SFTPsftp-upload-download
sftp = client.open_sftp()
sftp.put("local/app.tar.gz", "/srv/releases/app.tar.gz")
sftp.get("/var/log/app.log", "local/app.log")
sftp.chmod("/srv/releases/app.tar.gz", 0o640)
sftp.close()put() will not create missing parent directories and overwrites without warning. Wrap sftp.stat(path) in a try/except IOError to test existence; there is no exists().
List a remote directory with metadatasftp-list-directory
import stat
sftp = client.open_sftp()
for entry in sftp.listdir_attr("/srv/releases"):
kind = "dir" if stat.S_ISDIR(entry.st_mode) else "file"
print(f"{kind}\t{entry.st_size}\t{entry.filename}")listdir_attr gives you one round trip for names plus stat data; calling listdir() then stat() per file is an extra request each and gets slow over high-latency links.
Stream output line by line instead of bufferingstream-command-output
stdin, stdout, stderr = client.exec_command("tail -f /var/log/app.log", get_pty=True)
for line in iter(stdout.readline, ""):
print(line.rstrip())get_pty=True merges stderr into stdout and makes the remote side line-buffer. Without it many programs block-buffer and you see nothing until the process exits.
Reach a private host through a jump boxjump-host-tunnel
import paramiko
jump = paramiko.SSHClient()
jump.load_system_host_keys()
jump.connect("bastion.example.com", username="deploy")
channel = jump.get_transport().open_channel(
"direct-tcpip", dest_addr=("10.0.1.20", 22), src_addr=("127.0.0.1", 0)
)
target = paramiko.SSHClient()
target.load_system_host_keys()
target.connect("10.0.1.20", username="deploy", sock=channel)This is Paramiko's ProxyJump. Keep the jump client alive for as long as the target connection: closing it kills the channel underneath.
Honour ~/.ssh/config entriesread-ssh-config
import os, paramiko
cfg = paramiko.SSHConfig.from_path(os.path.expanduser("~/.ssh/config"))
opts = cfg.lookup("myhost")
client = paramiko.SSHClient()
client.load_system_host_keys()
client.connect(
opts["hostname"],
port=int(opts.get("port", 22)),
username=opts.get("user"),
key_filename=opts.get("identityfile"),
)Paramiko parses the config but never applies it. Everything (ProxyJump, IdentityFile, Port) must be wired into connect() by hand, which is one of the main reasons people move to Fabric.
Write a key out in OpenSSH formatconvert-key-format
from paramiko import PKey
from paramiko.pkey import OPENSSH
key = PKey.from_path("/home/me/.ssh/id_rsa", password="old-passphrase")
key.write_private_key_file(
"/home/me/.ssh/id_rsa_new", password="new-passphrase", file_format=OPENSSH
)file_format arrived in 5.0.0 and still defaults to legacy PEM; the changelog warns a future major will flip the default, so pass it explicitly now.
Stop long-lived connections from silently dyingkeepalive-and-timeouts
client.connect(
"host.example.com",
username="deploy",
timeout=10, # TCP connect
banner_timeout=15, # slow SSH banner
auth_timeout=20, # slow PAM/LDAP auth
)
client.get_transport().set_keepalive(30) # secondsThe three timeouts are separate and all default to None or generous values; without keepalive, NAT and load balancers drop idle sessions and the next read hangs instead of raising.
Disable weak or problematic algorithmsrestrict-algorithms
client.connect(
"host.example.com",
username="deploy",
disabled_algorithms={
"pubkeys": ["rsa-sha2-512", "rsa-sha2-256"],
},
)This dict is the standard workaround for servers that advertise SHA-2 RSA but reject it. Note that Paramiko 5 already removed SHA-1 kex and ssh-rsa signing, so you cannot re-enable those here.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| fabric | PyPI | You want the high-level command runner and file transfer API; same maintainer, built directly on Paramiko |
| asyncssh | PyPI | Your app is asyncio-based and you want SSH client and server without dedicating a thread per connection |
| scp | PyPI | You already have a Paramiko transport and only need scp-style file copying, which is faster than SFTP for bulk pushes |