Chapter 7: Socket Programming Fundamentals


What Are Sockets?

A socket is an endpoint for network communication. It’s the programming interface that lets your code send and receive data over a network. When you open a TCP connection, send a UDP datagram, or even ping a host, sockets are doing the work under the hood.

Sockets operate at the Transport layer (Layer 4). They abstract away everything below — Ethernet framing, IP routing, physical transmission — and give you a simple API: create a socket, connect it, send data, receive data, close it.

Every networked application you use — web browsers, email clients, chat apps, game servers — is built on sockets.


The Socket API

Python’s socket module provides a thin wrapper over the BSD socket API, which is the standard across all operating systems. The core operations are:

Operation TCP UDP
Create socket() socket()
Bind to address bind() bind()
Listen for connections listen()
Accept a connection accept()
Connect to server connect() optional
Send data send() / sendall() sendto()
Receive data recv() recvfrom()
Close close() close()

TCP Sockets — Connection-Oriented

TCP provides reliable, ordered, byte-stream delivery. Before exchanging data, client and server establish a connection via the three-way handshake.

TCP Client

import socket

# Create a TCP socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("example.com", 80))
    s.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
    response = s.recv(4096)
    print(response.decode())

Let’s break down the arguments:

TCP Server

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("0.0.0.0", 9000))
    s.listen(5)
    print("Server listening on port 9000...")

    while True:
        conn, addr = s.accept()
        with conn:
            print(f"Connection from {addr}")
            data = conn.recv(1024)
            if data:
                conn.sendall(data.upper())

Key details:

Full example: code/tcp_echo_server.py


UDP Sockets — Connectionless

UDP provides fast, best-effort delivery. There’s no connection setup, no reliability guarantees, and no ordering. Each sendto() call is an independent datagram.

UDP Client

import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.sendto(b"Hello, UDP!", ("127.0.0.1", 9001))
    data, addr = s.recvfrom(1024)
    print(f"Received from {addr}: {data.decode()}")

UDP Server

import socket

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.bind(("0.0.0.0", 9001))
    print("UDP server listening on port 9001...")

    while True:
        data, addr = s.recvfrom(1024)
        print(f"From {addr}: {data.decode()}")
        s.sendto(data.upper(), addr)

Note the differences from TCP:

Full example: code/udp_echo_server.py


TCP vs UDP — Choosing the Right Protocol

Feature TCP UDP
Connection Required (3-way handshake) None
Reliability Guaranteed delivery, retransmission Best-effort, may lose packets
Ordering Guaranteed in-order delivery No ordering guarantees
Flow control Yes (sliding window) None
Overhead Higher (headers, handshake, ACKs) Lower (minimal header)
Use cases HTTP, SSH, email, file transfer DNS, gaming, video streaming, VoIP

Use TCP when you need every byte to arrive correctly and in order. Use UDP when speed matters more than reliability, or when the application handles its own reliability.


IPv6 Socket Programming

Supporting IPv6 requires minimal changes:

import socket

# IPv6 TCP client
with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
    s.connect(("::1", 9000))  # Connect to localhost via IPv6
    s.sendall(b"Hello, IPv6!")
    print(s.recv(1024).decode())

Dual-Stack Sockets

A dual-stack socket handles both IPv4 and IPv6 connections:

import socket

with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s:
    s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("::", 9000))  # Bind to all IPv4 and IPv6 addresses
    s.listen(5)
    print("Dual-stack server on port 9000")
    # Accepts both IPv4 and IPv6 connections

Note: On Linux, dual-stack is the default behavior for AF_INET6 sockets. On some systems, you need to explicitly set IPV6_V6ONLY to 0.


Blocking vs Non-Blocking Sockets

By default, socket operations block — they pause your program until the operation completes. This is simple but doesn’t scale to multiple clients.

Setting Timeouts

import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.settimeout(5.0)  # 5-second timeout
    try:
        s.connect(("example.com", 80))
        s.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
        data = s.recv(4096)
    except socket.timeout:
        print("Connection timed out!")

Non-Blocking Mode

import socket
import select

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.setblocking(False)
    try:
        s.connect(("example.com", 80))
    except BlockingIOError:
        pass  # Connection in progress

    # Wait until the socket is writable (connected)
    _, writable, _ = select.select([], [s], [], 5.0)
    if writable:
        s.sendall(b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
        readable, _, _ = select.select([s], [], [], 5.0)
        if readable:
            print(s.recv(4096).decode())

The select module monitors multiple sockets and tells you which ones are ready for reading or writing. This is the foundation of event-driven networking (covered in depth in Chapter 9).


Socket Options

Socket options fine-tune socket behavior:

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# Reuse address — essential for servers
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# Reuse port — allows multiple processes to bind to the same port
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)

# TCP keepalive — detect dead connections
s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)

# TCP_NODELAY — disable Nagle's algorithm for low-latency
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)

# Set send/receive buffer sizes
s.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 65536)
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536)
Option Purpose
SO_REUSEADDR Allow binding to a port in TIME_WAIT state
SO_REUSEPORT Allow multiple sockets to bind to the same port
SO_KEEPALIVE Send periodic probes to detect dead connections
TCP_NODELAY Send data immediately (disable buffering)
SO_SNDBUF / SO_RCVBUF Set kernel buffer sizes
SO_LINGER Control behavior on close() with unsent data

Handling Partial Data

A critical concept: recv() may return fewer bytes than you expect. TCP is a byte stream, not a message stream — the kernel can split or merge your sends.

def recv_exactly(sock: socket.socket, n: int) -> bytes:
    """Receive exactly n bytes from a socket."""
    data = b""
    while len(data) < n:
        chunk = sock.recv(n - len(data))
        if not chunk:
            raise ConnectionError("Connection closed")
        data += chunk
    return data

For message-based protocols, you need a framing mechanism:

import struct

def send_message(sock, msg: bytes) -> None:
    """Send a length-prefixed message."""
    length = struct.pack("!I", len(msg))
    sock.sendall(length + msg)

def recv_message(sock) -> bytes:
    """Receive a length-prefixed message."""
    raw_len = recv_exactly(sock, 4)
    msg_len = struct.unpack("!I", raw_len)[0]
    return recv_exactly(sock, msg_len)

Key Takeaways


← Previous: TLS, Encryption, and Certificates Table of Contents Next: Building TCP and UDP Servers →