Socket Programming in Python
Talking to the Kernel Directly
Every HTTP client, database driver and message broker ends up calling the same small API: the Berkeley sockets interface. Python's socket module is a thin wrapper around it, so what you learn here carries over to C, Go, Rust and Java almost unchanged. Writing your own client and server is also the fastest way to understand why TCP behaves the way it does, and why so much networking code is subtly broken.
The Lifecycle of a TCP Connection in Code
A server needs one listening socket plus one connected socket per client. The calls map onto the handshake:
| Call | Side | What the kernel does |
|---|---|---|
socket(AF_INET, SOCK_STREAM) |
both | allocates a socket; nothing on the wire |
bind((host, port)) |
server | claims an address and port; fails with Address already in use if taken |
listen(backlog) |
server | starts answering SYNs; completed handshakes queue up |
connect((host, port)) |
client | picks an ephemeral source port, sends SYN, returns after the SYN-ACK |
accept() |
server | takes one completed connection off the queue, returns a new socket |
send / recv |
both | copy bytes into and out of kernel buffers |
shutdown(SHUT_WR) |
either | sends FIN: "I will send nothing more", but can still receive |
close() |
either | releases the socket; FIN (or RST if unread data remains) |
Two points trip people up. The handshake is completed by the kernel, not by accept(): a client can connect successfully to a server that never calls accept(), until the backlog fills. And send and recv do not talk to the network at all. They move bytes between your program and kernel buffers; TCP decides when and how those bytes travel.
Python provides two helpers that do the boilerplate correctly: socket.create_server() (socket, SO_REUSEADDR on POSIX, bind, listen) and socket.create_connection() (resolves the name, tries each returned address including IPv6, applies a timeout).
A Threaded Server with Real Framing
TCP is a byte stream, so a protocol must define where each message ends. The TCP vs UDP lesson shows length-prefixed framing; here is the other common choice, newline-delimited JSON, with the defensive details a real server needs:
import json, socket, threading
MAX_LINE = 64 * 1024 # refuse lines longer than this
def handle(conn, addr):
conn.settimeout(30) # an idle client cannot hold a thread forever
with conn, conn.makefile("rb") as reader:
while True:
line = reader.readline(MAX_LINE + 1) # returns one complete line, or less at EOF
if not line: # b"": the client closed its side
break
if not line.endswith(b"\n"): # too long, or cut off by EOF
conn.sendall(b'{"error": "bad frame"}\n')
break
try:
reply = {"echo": json.loads(line), "peer": addr[1]}
except json.JSONDecodeError:
reply = {"error": "bad json"}
conn.sendall(json.dumps(reply).encode() + b"\n")
def serve(listener):
while True:
conn, addr = listener.accept() # blocks until a handshake completes
threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
listener = socket.create_server(("127.0.0.1", 0)) # bind + listen; port 0 = any free port
port = listener.getsockname()[1]
threading.Thread(target=serve, args=(listener,), daemon=True).start()
with socket.create_connection(("127.0.0.1", port), timeout=5) as client:
client.sendall(b'{"n": 1}\n{"n": 2}\n{"n"') # two whole messages and part of a third
client.sendall(b': 3}\nnot json\n') # the rest arrives in a later write
client.shutdown(socket.SHUT_WR) # half-close: "I have nothing more to send"
with client.makefile("rb") as replies:
for line in replies:
print(line.decode().rstrip())
Output (the port number varies):
{"echo": {"n": 1}, "peer": 63327}
{"echo": {"n": 2}, "peer": 63327}
{"echo": {"n": 3}, "peer": 63327}
{"error": "bad json"}
What each non-obvious line buys you:
makefile("rb")plusreadline()puts a buffer in front of the socket. It keeps callingrecvuntil it has a full line and holds any extra bytes for the next call, which is exactly the reassembly the client's split write requires.- The
MAX_LINE + 1limit. A barefor line in readerwould happily buffer gigabytes from a client that never sends a newline. Every framing scheme needs a maximum message size, including length prefixes, where a hostile 4-byte header can claim a 4 GB body. if not line.recvreturning an empty bytes object is the only way you learn that the peer closed the connection. Forgetting this check produces a loop that spins at 100% CPU.sendallinstead ofsend.sendmay accept only part of the data and returns how many bytes it took.sendallloops until everything is queued.shutdown(SHUT_WR)sends a FIN without closing the socket, so the server sees EOF and the client can still read the replies. Closing instead would discard them.
One thread per client is fine for dozens or low hundreds of connections; beyond that, non-blocking I/O wins.
Timeouts: Blocking Forever Is the Default
A fresh socket blocks indefinitely: a recv whose peer vanished without a FIN (pulled cable, expired NAT state) waits for hours. Set a timeout on every socket:
create_connection(addr, timeout=5)bounds the connect and sets the same timeout on later operations.sock.settimeout(30)applies per call, not per message. A client that dribbles one byte every 29 seconds keeps areadlinealive indefinitely, so servers that care also enforce an overall deadline per request.- A timeout raises
TimeoutError(socket.timeoutis an alias since Python 3.10). After one, treat the connection as unusable: you do not know how much of a message was consumed.
For long-lived idle connections, add an application-level ping (or TCP keepalive via SO_KEEPALIVE) so dead peers are detected.
Non-Blocking Sockets
In non-blocking mode, a call that cannot complete immediately raises instead of waiting:
import socket
listener = socket.create_server(("127.0.0.1", 0))
client = socket.create_connection(listener.getsockname())
server_side, _ = listener.accept() # accepted, but this side never reads
client.setblocking(False)
try:
client.recv(1024) # nothing has arrived yet
except BlockingIOError as e:
print("recv:", repr(e))
total = 0
chunk = b"x" * 65536
try:
while True:
total += client.send(chunk) # returns how many bytes the kernel took
except BlockingIOError:
print(f"send: buffers full after {total:,} bytes")
On Windows:
recv: BlockingIOError(10035, 'A non-blocking socket operation could not be completed immediately', None, 10035, None)
send: buffers full after 458,752 bytes
On Linux the error reads [Errno 11] Resource temporarily unavailable, and the byte count depends on buffer sizes. The second result is backpressure: when a receiver stops reading, its receive buffer fills, then the sender's send buffer, and writes stall. A program that queues the excess in its own memory instead will eventually run out.
Nobody writes non-blocking servers by catching BlockingIOError in loops. You register sockets with an OS readiness API (epoll on Linux, kqueue on BSD and macOS, wrapped by Python's selectors module) and act only on sockets that are ready. That event loop is exactly what asyncio provides.
The Same Server with asyncio Streams
import asyncio
async def handle(reader, writer):
peer = writer.get_extra_info("peername")
try:
while True:
line = await asyncio.wait_for(reader.readline(), timeout=30)
if not line: # EOF
break
writer.write(line.upper())
await writer.drain() # wait if the send buffer is full
except (asyncio.TimeoutError, ValueError, ConnectionError) as e:
print(f"dropping {peer}: {type(e).__name__}") # ValueError = line over the limit
finally:
writer.close()
await writer.wait_closed()
async def client(port, i):
reader, writer = await asyncio.open_connection("127.0.0.1", port)
writer.write(f"hello from client {i}\n".encode())
await writer.drain()
reply = await asyncio.wait_for(reader.readline(), timeout=5)
writer.close()
await writer.wait_closed()
return reply.decode().strip()
async def main():
server = await asyncio.start_server(handle, "127.0.0.1", 0, limit=64 * 1024)
port = server.sockets[0].getsockname()[1]
async with server:
print(await asyncio.gather(*(client(port, i) for i in range(3))))
asyncio.run(main())
['HELLO FROM CLIENT 0', 'HELLO FROM CLIENT 1', 'HELLO FROM CLIENT 2']
One thread now serves thousands of connections. The details that matter: limit= caps the buffer, and readline() raises ValueError when a line exceeds it; readexactly(n) is the tool for length-prefixed protocols; wait_for supplies the timeout that streams lack by default; and await writer.drain() is how backpressure reaches your code. Calling write() in a loop without drain() buffers without bound. Any blocking call inside a handler (time.sleep, a synchronous database driver, requests) freezes every connection at once.
A note on UDP. UDP sockets (SOCK_DGRAM, covered in TCP vs UDP) have no listen or accept. One extra trick: connect() on a UDP socket sends nothing, but fixes the peer address so ICMP errors surface as exceptions.
Common Bugs and Their Symptoms
| Bug | Symptom | Fix |
|---|---|---|
Assuming one recv equals one message |
works on localhost, corrupts data over real links | framing: delimiter or length prefix, with a buffer |
send instead of sendall |
truncated messages under load | sendall, or drain() in asyncio |
Ignoring recv() == b"" |
busy loop at 100% CPU after the client leaves | treat empty read as EOF and close |
| No timeouts | threads or tasks pile up; ss shows stuck ESTABLISHED sockets |
timeouts on connect and every read |
| Not closing on error paths | CLOSE-WAIT count grows | with blocks or try/finally |
Binding to 127.0.0.1 |
works locally, "connection refused" from other hosts | bind 0.0.0.0 / :: deliberately, behind a firewall |
Binding 0.0.0.0 only |
IPv6 clients refused | create_server(("", port), family=socket.AF_INET6, dualstack_ipv6=True) |
Restart fails with Address already in use |
old connections in TIME_WAIT | SO_REUSEADDR (create_server sets it on POSIX); on Windows it permits port hijacking, so leave it off there |
Verifying from the Outside
Test your server with tools that do not share your bugs:
ss -ltnp | grep 8000 # is it listening, on which address, which process?
printf '{"n": 1}\n' | nc -q1 127.0.0.1 8000 # send one framed message (-q1: GNU/Debian netcat)
sudo tcpdump -n -i lo 'tcp port 8000' -A # watch the bytes and the FINs
If ss shows the socket bound to 127.0.0.1:8000, it is unreachable from other machines no matter what the firewall says. For which ports are conventional and how to see who owns them, see Common Ports and Protocols.
When to Use Raw Sockets at All
Use them to learn, for a small custom protocol between systems you control, or for a protocol with no library. For anything standard, use the library, which already handles framing, pooling and TLS. If your own protocol needs encryption, wrap the socket with ssl.create_default_context().wrap_socket(...) as shown in The TLS Handshake.
Practice
Change the threaded server to use a 4-byte length prefix instead of newlines, enforce a 1 MB maximum, and write a client that sends a message one byte per send call with a short sleep in between. Then connect with nc, type nothing, and confirm that your timeout closes the connection and ss -tan shows no leftover sockets.