socket

The socket module gives you direct access to the operating system's networking primitives — the low-level building block that libraries like requests and asyncio are built on top of.

A socket is an operating-system object representing one endpoint of a network connection. Python's socket module wraps the C sockets API almost directly, so understanding it teaches you how networking actually works underneath every higher-level HTTP or RPC library you will ever use.

What Is a Socket, and Why Learn the Low-Level API

A socket is a handle the operating system gives a process to send and receive bytes over a network (or between processes on the same machine, via Unix domain sockets). Every higher-level networking tool — http.client, requests, database drivers, asyncio transports — ultimately creates and drives sockets underneath.

Learning socket directly matters because it makes the underlying model explicit: connections are streams of bytes with no built-in message boundaries, errors are OS-level exceptions, and resource cleanup is your responsibility. That mental model transfers to every networking library you use afterward.

This topic covers raw TCP/UDP sockets. It does not re-teach HTTP request/response semantics (see the HTTP Client topic) or asyncio's socket-based transports.

Creating a Socket: Families and Types

socket.socket(family, type) creates a socket. The two arguments you almost always specify are the address family (what kind of address, e.g. IPv4 vs IPv6) and the socket type (what delivery guarantees you want).

AF_INET is by far the most common family (IPv4 addresses like ('host', port) tuples). SOCK_STREAM means TCP; SOCK_DGRAM means UDP.

socket.AF_UNIX (POSIX only) creates a Unix domain socket for fast local inter-process communication using filesystem paths instead of IP addresses — useful, but outside this topic's scope.

import socket

# IPv4, TCP (reliable, ordered, connection-oriented byte stream)
tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# IPv4, UDP (connectionless, unordered, message-based, no delivery guarantee)
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

# IPv6 works the same way with AF_INET6
tcp6_sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)

The TCP Client Side: connect, send, recv

A TCP client connects to a listening server at a known (host, port) address, then exchanges bytes over the resulting reliable, ordered stream.

sock.connect() blocks until the TCP handshake completes or raises an error (e.g. ConnectionRefusedError). sendall() loops internally until all bytes are sent; recv(n) returns up to n bytes, and an empty return means the peer closed its side.

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect(("example.com", 80))
    request = b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"
    sock.sendall(request)

    chunks = []
    while True:
        chunk = sock.recv(4096)
        if not chunk:          # empty bytes means the peer closed the connection
            break
        chunks.append(chunk)

    response = b"".join(chunks)
    print(response[:200])

The TCP Server Side: bind, listen, accept

A TCP server follows a fixed sequence: create a socket, bind it to a local address, put it into listening mode, then repeatedly accept incoming connections.

bind() reserves the local address. listen() marks the socket as passive/listening and sets how many pending connections the OS will queue. accept() blocks and returns a brand-new connected socket for that one client — the original listening socket keeps listening for more.

SO_REUSEADDR lets you restart a server immediately after it stops, without waiting for the OS to release a socket stuck in TIME_WAIT. It is a common, safe default for development servers.

import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", 8080))
server.listen(backlog=128)          # start listening; backlog = pending-connection queue size
print("Listening on port 8080...")

try:
    while True:
        conn, addr = server.accept()   # blocks until a client connects
        with conn:
            print("Connected by", addr)
            data = conn.recv(1024)
            conn.sendall(b"echo: " + data)
finally:
    server.close()

send/recv Semantics: Streams, Not Messages

The single most important fact about TCP sockets: they are byte streams, not message channels. TCP makes no promise that one send() on one side corresponds to one recv() on the other. A 10,000-byte send might arrive as five separate recv() calls, or two sends might be coalesced into one recv().

This means application protocols must define their own message framing — a way to know where one message ends and the next begins. Common strategies: a fixed-length header stating the payload size, a delimiter (like HTTP's \r\n\r\n), or a self-describing format that signals its own end (like JSON with newline-delimited messages).

recv_exactly loops until it has exactly the requested number of bytes, because a single recv() call is only guaranteed to return 'at most n bytes', never 'exactly n bytes'.

import struct

def send_message(sock, payload: bytes) -> None:
    header = struct.pack("!I", len(payload))   # 4-byte big-endian length prefix
    sock.sendall(header + payload)

def recv_exactly(sock, n: int) -> bytes:
    buf = bytearray()
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("socket closed before n bytes were received")
        buf.extend(chunk)
    return bytes(buf)

def recv_message(sock) -> bytes:
    header = recv_exactly(sock, 4)
    (length,) = struct.unpack("!I", header)
    return recv_exactly(sock, length)

shutdown vs close

close() releases the socket's file descriptor entirely; after close(), the socket object is unusable. shutdown(how) is more surgical: it disables sending, receiving, or both on an otherwise still-open socket, which lets you signal 'I'm done sending' to the peer while still reading its remaining response.

SHUT_WR half-closes the write side; SHUT_RD half-closes the read side; SHUT_RDWR disables both without releasing the descriptor. Most simple scripts skip shutdown() and just call close(), which is fine for short-lived connections.

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("example.com", 80))
sock.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
sock.shutdown(socket.SHUT_WR)   # signal EOF to the peer, keep reading allowed

data = sock.recv(65536)         # still works after shutdown(SHUT_WR)
sock.close()                    # now fully release the socket

Timeouts and Blocking Modes

By default, sockets are blocking: connect(), recv(), and accept() wait indefinitely. A hung or malicious peer can therefore freeze your program forever. settimeout() bounds every blocking call on that socket and raises socket.timeout (an alias of TimeoutError since Python 3.10) if the operation doesn't complete in time.

settimeout(0) makes the socket fully non-blocking (calls raise BlockingIOError immediately instead of waiting) — the basis for building your own event loops. settimeout(None) restores blocking mode.

Always set a timeout on client sockets that talk to the outside world. An unbounded recv() is one of the most common causes of a script that 'just hangs' in production.

import socket

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)   # 5-second timeout on connect, recv, etc.
try:
    sock.connect(("example.com", 80))
    data = sock.recv(4096)
except TimeoutError:
    print("Operation timed out")
except OSError as e:
    print("Connection failed:", e)
finally:
    sock.close()

UDP Sockets: Connectionless Datagrams

UDP (SOCK_DGRAM) sends discrete messages (datagrams) with no connection setup, no ordering guarantee, and no delivery guarantee — a datagram can be lost, duplicated, or arrive out of order. In exchange it has lower overhead and no head-of-line blocking, which suits things like DNS queries, telemetry, and real-time media.

UDP has no listen()/accept() — there is no connection to accept, just datagrams addressed to a bound port. recvfrom()/sendto() carry the peer address explicitly on each call.

import socket

# UDP server
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("0.0.0.0", 9999))
data, client_addr = server.recvfrom(4096)   # returns (data, sender_address)
server.sendto(b"ack: " + data, client_addr)

# UDP client
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.sendto(b"hello", ("127.0.0.1", 9999))
reply, _ = client.recvfrom(4096)

Resource Cleanup

Sockets hold a limited OS resource (a file descriptor); leaking them eventually exhausts the process's descriptor limit. socket objects are context managers — with socket.socket(...) as s: guarantees close() runs even if an exception is raised inside the block.

For server code accepting many connections, wrap each accepted connection socket in its own with block (or try/finally) so a bug handling one client cannot leak that client's socket.

import socket

def fetch(host: str, port: int, request: bytes) -> bytes:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(10)
        sock.connect((host, port))
        sock.sendall(request)
        chunks = []
        while chunk := sock.recv(4096):
            chunks.append(chunk)
        return b"".join(chunks)
    # socket is closed here automatically, even on exception

Complete Example: A Minimal Echo Server and Client

# server.py
import socket

def run_server(host="127.0.0.1", port=5050):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind((host, port))
        server.listen()
        print(f"Echo server listening on {host}:{port}")
        while True:
            conn, addr = server.accept()
            with conn:
                print("Client connected:", addr)
                while True:
                    data = conn.recv(1024)
                    if not data:
                        break
                    conn.sendall(data)   # echo back

if __name__ == "__main__":
    run_server()
# client.py
import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
    sock.connect(("127.0.0.1", 5050))
    sock.sendall(b"hello, server")
    print(sock.recv(1024))   # b"hello, server"

Edge Cases and Error Handling

Networking code must handle failures that never occur in purely local code: the peer resets the connection (ConnectionResetError), the destination is unreachable (OSError), a DNS lookup fails (socket.gaierror), or the connection is refused because nothing is listening (ConnectionRefusedError).

Two subtleties worth knowing: a recv() of 0 bytes means a clean, graceful close by the peer — not an error and not 'no data available'. And listen(backlog)'s backlog only bounds pending, not-yet-accept()ed connections; it does not limit total concurrent clients.

import socket

try:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.settimeout(3)
        sock.connect(("nonexistent.invalid", 80))
except socket.gaierror as e:
    print("DNS lookup failed:", e)
except ConnectionRefusedError:
    print("Nothing is listening on that port")
except TimeoutError:
    print("Connection attempt timed out")
except OSError as e:
    print("Other socket error:", e)

Mental Model and Quick Reference

Think of a TCP socket as two independent one-way pipes glued together: a write pipe and a read pipe, each an unstructured byte stream. Nothing about the socket itself knows about 'messages' — that structure is a contract your application code must impose and enforce.

Client side               Server side
------------               ------------
socket()                   socket()
connect() ---------------> bind()
                            listen()
                            accept()  <-- returns new connected socket
send()/sendall() --------> recv()
recv() <------------------ send()/sendall()
close()                    close()

Key Takeaways

TCP sockets are ordered, reliable byte streams with no built-in message boundaries — your protocol must define its own framing.

The server-side sequence is socket → bind → listen → accept; accept() returns a brand-new socket per client.

sendall() loops until all bytes go out; a single recv() call may return fewer bytes than requested — loop until you have what you need.

An empty bytes object from recv() means the peer closed the connection, not 'no data yet'.

Always set a timeout on client sockets talking to the outside world to avoid hanging forever.

UDP trades reliability and ordering for lower overhead and no connection setup.

Use with (or try/finally) to guarantee sockets are closed, even on exceptions.

data = sock.recv(1024) process(data) # assumes this is the whole message expected = 1024 buf = bytearray() while len(buf) < expected: chunk = sock.recv(expected - len(buf)) if not chunk: raise ConnectionError("closed early") buf.extend(chunk)

A single recv() call is only guaranteed to return 'up to n bytes', not exactly n or a whole logical message. Always loop until you have the amount your protocol expects.

send() may write only part of the data and returns how many bytes it actually sent; sendall() loops internally and either sends everything or raises.

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) # no timeout set sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(10) sock.connect((host, port))

Without a timeout, connect()/recv() can block forever if the peer is slow, unresponsive, or malicious.

server.bind(("0.0.0.0", 8080)) # forgot setsockopt; restart fails with 'Address already in use' server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind(("0.0.0.0", 8080))

Without SO_REUSEADDR, quickly restarting a server can fail because the OS holds the address in TIME_WAIT for a while after the previous socket closed.

conn, addr = server.accept() data = conn.recv(1024) # conn.close() never called on early return/exception conn, addr = server.accept() with conn: data = conn.recv(1024)

Every accepted connection socket must be closed; using it as a context manager guarantees cleanup even if handling the connection raises.

Why can a single call to recv() return fewer bytes than were sent in one send() call?

TCP is a byte stream with no message boundaries; the OS and network are free to split or coalesce data into whatever chunks are convenient, so recv() only guarantees 'up to n bytes now available', never a specific amount.

What does it mean when sock.recv(n) returns b''?

It means the peer has performed an orderly shutdown of its write side (or closed the connection) — it is 'end of stream', not an error and not 'try again later'.

What is the difference between close() and shutdown() on a socket?

shutdown(how) disables sending and/or receiving on a still-open socket (letting you signal you're done sending while still reading a reply); close() releases the underlying file descriptor entirely, making the socket object unusable.

Why does a TCP server call listen() and accept() separately from the original socket?

listen() marks the original socket as a passive listener with a backlog queue; accept() dequeues one pending connection and returns a brand-new socket dedicated to that client, leaving the original socket free to keep listening for others.

What happens if you never set a timeout on a client socket connecting to an unreliable server?

connect() or recv() can block indefinitely if the server never responds, effectively hanging your program with no way to recover short of external intervention (like a process kill).

Why is UDP unsuitable for transferring a large file reliably without extra work?

UDP provides no delivery guarantee, ordering guarantee, or built-in retransmission — datagrams can be lost, duplicated, or reordered, so reliable transfer requires the application to add its own acknowledgment and retry logic (which is essentially reinventing part of TCP).

Why must application protocols define their own message framing on top of TCP?

Because TCP only guarantees an ordered, reliable byte stream — it has no concept of 'messages' at all, so the application must encode where one logical message ends and the next begins, e.g. via length prefixes or delimiters.

Write a function recv_line(sock) that reads bytes from a socket one chunk at a time and returns everything up to (and including) the first b'\n', buffering any extra bytes read past the newline is not required (assume at most one line per call for this exercise).

def recv_line(sock, chunk_size: int = 1) -> bytes: line = bytearray() while True: chunk = sock.recv(chunk_size) if not chunk: break line.extend(chunk) if b"\n" in chunk: break return bytes(line)

The function loops on recv(), appending bytes until it sees a newline or the connection closes (empty recv), since a single recv() call cannot be assumed to return a complete line.

What kind of exception (broadly) is caught here, and why is catching OSError a reasonable choice for socket connection code?

socket.gaierror, ConnectionRefusedError, and TimeoutError (via socket.timeout) are all subclasses of OSError, so catching OSError is a safe broad net for 'something went wrong establishing the connection' when you don't need to distinguish the exact cause.

Write a length-prefixed message sender and receiver pair using struct, where the prefix is a 4-byte unsigned big-endian integer giving the payload length.

import struct def send_msg(sock, payload: bytes) -> None: sock.sendall(struct.pack("!I", len(payload)) + payload) def recv_msg(sock) -> bytes: raw_len = _recv_exact(sock, 4) (length,) = struct.unpack("!I", raw_len) return _recv_exact(sock, length) def _recv_exact(sock, n: int) -> bytes: buf = bytearray() while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("connection closed early") buf.extend(chunk) return bytes(buf)

The sender prefixes the payload with its length; the receiver first reads exactly 4 bytes to learn the length, then reads exactly that many more bytes, looping recv() calls because TCP gives no guarantee a single call returns everything requested.

What does this print, and why?

It prints 'False False'. Each accept() call returns a brand-new socket object dedicated to that client connection; the original server socket remains the listening socket and is never itself used to exchange data.

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(2)
try:
    s.connect(("127.0.0.1", 9), )  # port 9 (discard) usually refuses or filters
except OSError as e:
    print(type(e).__name__)
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("0.0.0.0", 8080))
server.listen()
conn1, addr1 = server.accept()
conn2, addr2 = server.accept()
print(conn1 is server, conn2 is server)