Intermediate

Every time your Python script calls requests.get(), sends an email with smtplib, or connects to a database, it is using sockets underneath. Sockets are the fundamental building blocks of network communication — raw endpoints that let two programs exchange bytes over a network. Understanding how they work demystifies how all higher-level networking libraries operate, and gives you the ability to build custom protocols, network tools, and low-latency services that no library covers.

Python’s socket module is part of the standard library — no installation needed. You will need two terminal windows to run the server and client examples side by side, since they run as separate processes. All examples run on localhost so no external network access is required.

This article covers: how sockets work conceptually, creating a TCP server and client, handling multiple connections with threading, UDP sockets, setting timeouts, and a real-life port scanner that checks which ports are open on a host.

TCP Echo Server: Quick Example

The simplest possible socket program is an echo server — it receives bytes and sends them straight back. Run the server first, then the client in a second terminal.

# tcp_echo_server.py
import socket

HOST = "127.0.0.1"
PORT = 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.bind((HOST, PORT))
    server.listen(1)
    print(f"Listening on {HOST}:{PORT}")
    conn, addr = server.accept()
    with conn:
        print(f"Connected by {addr}")
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)
        print("Client disconnected")
# tcp_echo_client.py
import socket

HOST = "127.0.0.1"
PORT = 65432

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
    client.connect((HOST, PORT))
    client.sendall(b"Hello from client!")
    data = client.recv(1024)
    print("Received:", data.decode())

Server output:

Listening on 127.0.0.1:65432
Connected by ('127.0.0.1', 55234)
Client disconnected

Client output:

Received: Hello from client!

The server binds to an address, listens for incoming connections, and then handles one connection at a time. The client connects, sends a message, receives the echo, and closes. The with statement ensures the socket is closed properly even if an exception occurs.

How Sockets and TCP Work

A socket is one endpoint of a two-way communication channel. TCP (Transmission Control Protocol) guarantees reliable, ordered delivery — bytes sent in order always arrive in the same order. UDP (User Datagram Protocol) is faster but offers no delivery guarantees. Most networked applications use TCP.

ConceptDescriptionPython socket call
Address familyIPv4 or IPv6AF_INET or AF_INET6
Socket typeTCP or UDPSOCK_STREAM or SOCK_DGRAM
BindAttach socket to an address:portsocket.bind((host, port))
ListenQueue up incoming connectionssocket.listen(backlog)
AcceptBlock until a client connectsconn, addr = socket.accept()
ConnectInitiate connection to a serversocket.connect((host, port))
SendSend bytes to the other endsocket.sendall(data)
ReceiveRead bytes from the bufferdata = socket.recv(bufsize)

TCP is connection-oriented: the server and client complete a three-way handshake before any data flows. UDP is connectionless: you just send packets to an address without establishing a connection first. Choose TCP when you need reliability; choose UDP when you need raw speed and can tolerate dropped packets (video streaming, DNS, real-time games).

Handling Multiple Clients with Threading

The simple server above handles only one client at a time. In practice you need to handle many simultaneous connections. The easiest approach is to spawn a thread for each accepted connection.

# tcp_threaded_server.py
import socket
import threading

HOST = "127.0.0.1"
PORT = 65433

def handle_client(conn: socket.socket, addr: tuple):
    print(f"[{addr}] connected")
    with conn:
        while True:
            data = conn.recv(1024)
            if not data:
                break
            message = data.decode().strip()
            response = f"Echo: {message.upper()}\n"
            conn.sendall(response.encode())
    print(f"[{addr}] disconnected")

def start_server():
    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(10)
        print(f"Server running on {HOST}:{PORT}")
        while True:
            conn, addr = server.accept()
            t = threading.Thread(target=handle_client, args=(conn, addr))
            t.daemon = True
            t.start()

start_server()

Output (with two simultaneous client connections):

Server running on 127.0.0.1:65433
[('127.0.0.1', 55100)] connected
[('127.0.0.1', 55101)] connected
[('127.0.0.1', 55100)] disconnected
[('127.0.0.1', 55101)] disconnected

server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) is a critical line that lets you restart the server immediately without waiting for the OS to release the port. Without it you will get “Address already in use” errors when restarting during development. Setting threads as daemon with t.daemon = True means they automatically exit when the main program ends.

UDP Sockets

UDP is useful for low-latency applications that can tolerate occasional packet loss — think status monitoring, game state updates, or DNS queries. There is no connection handshake; you just send and receive datagrams.

# udp_server.py
import socket

HOST = "127.0.0.1"
PORT = 65434

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
    server.bind((HOST, PORT))
    print(f"UDP server on {HOST}:{PORT}")
    while True:
        data, addr = server.recvfrom(1024)
        print(f"From {addr}: {data.decode()}")
        server.sendto(b"ACK: " + data, addr)
# udp_client.py
import socket

HOST = "127.0.0.1"
PORT = 65434

with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
    client.sendto(b"Status check OK", (HOST, PORT))
    response, _ = client.recvfrom(1024)
    print("Response:", response.decode())

Client output:

Response: ACK: Status check OK

The key difference: UDP uses SOCK_DGRAM instead of SOCK_STREAM, and instead of accept() + send() you use recvfrom() (which returns both the data and the sender’s address) and sendto() (which requires an explicit destination address). No connect(), no listen(), no bind() on the client side.

Setting Timeouts

By default, socket operations block indefinitely. In a real application you must set timeouts to prevent your program from hanging if a server is slow or unreachable. Use socket.settimeout(seconds) before connecting or receiving.

# socket_timeout.py
import socket

def check_host(host: str, port: int, timeout: float = 2.0) -> bool:
    """Return True if the host:port accepts TCP connections within timeout."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        try:
            s.connect((host, port))
            return True
        except (socket.timeout, ConnectionRefusedError, OSError):
            return False

# Check a few well-known ports on a public host
tests = [
    ("httpbin.org", 80),   # HTTP
    ("httpbin.org", 443),  # HTTPS
    ("httpbin.org", 25),   # SMTP (likely blocked)
]

for host, port in tests:
    status = "OPEN" if check_host(host, port) else "CLOSED/FILTERED"
    print(f"{host}:{port} -- {status}")

Output:

httpbin.org:80 -- OPEN
httpbin.org:443 -- OPEN
httpbin.org:25 -- CLOSED/FILTERED

The socket.timeout exception is raised when the timeout expires before the operation completes. Catching ConnectionRefusedError handles the case where the port is actively closed (the server is running but nothing listens on that port). Catching OSError covers other network errors like unreachable hosts.

Real-Life Example: Concurrent Port Scanner

This scanner uses ThreadPoolExecutor to check a range of ports concurrently and prints a sorted list of open ones — a useful diagnostic tool for checking what services are running on a server.

# port_scanner.py
import socket
from concurrent.futures import ThreadPoolExecutor, as_completed

def scan_port(host: str, port: int, timeout: float = 1.0) -> tuple[int, bool]:
    """Return (port, is_open)."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        try:
            s.connect((host, port))
            return port, True
        except (socket.timeout, ConnectionRefusedError, OSError):
            return port, False

def scan_host(host: str, start: int = 1, end: int = 1024, workers: int = 100) -> list[int]:
    """Return a sorted list of open ports in the range [start, end]."""
    open_ports = []
    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {executor.submit(scan_port, host, p): p for p in range(start, end + 1)}
        for future in as_completed(futures):
            port, is_open = future.result()
            if is_open:
                open_ports.append(port)
    return sorted(open_ports)

if __name__ == "__main__":
    TARGET = "scanme.nmap.org"  # nmap's public test host (safe to scan)
    print(f"Scanning {TARGET} ports 1-1024...")
    open_ports = scan_host(TARGET, 1, 1024, workers=200)
    if open_ports:
        for p in open_ports:
            try:
                service = socket.getservbyport(p)
            except OSError:
                service = "unknown"
            print(f"  Port {p:5d} -- {service}")
    else:
        print("No open ports found.")

Output:

Scanning scanme.nmap.org ports 1-1024...
  Port    22 -- ssh
  Port    80 -- http

Using 200 concurrent threads drops scan time for 1024 ports from ~1024 seconds (sequential with 1s timeout) to about 5-10 seconds. socket.getservbyport(p) looks up the standard service name for a port number — it returns “ssh” for 22, “http” for 80, etc. Scan only hosts you own or have explicit permission to test; scanme.nmap.org is specifically provided by the Nmap project for public testing.

Frequently Asked Questions

When should I use the socket module vs requests/httpx?

Use requests or httpx for HTTP/HTTPS communication — they handle headers, encoding, redirects, sessions, and TLS automatically. Use the raw socket module when you are implementing a custom protocol (a game protocol, a custom binary API, raw TCP messaging), doing network diagnostics (port scanning, ping), or building network tools from scratch. For anything HTTP-related, a higher-level library saves you weeks of work.

Why do I get “Address already in use” when restarting my server?

The OS keeps a socket in TIME_WAIT state for a short period after it is closed, to ensure any delayed packets from the old connection are ignored. To bypass this during development, set the SO_REUSEADDR option before calling bind(): server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1). In production, you can also use SO_REUSEPORT to allow multiple processes to bind to the same port (useful for load balancing).

Why does recv() sometimes return partial data?

TCP is a stream protocol — it delivers bytes in order but with no guaranteed message boundaries. A single sendall(b"Hello World") on the sender might arrive as two separate recv() calls on the receiver. If you are building a protocol on top of TCP, you need a framing mechanism: either send a fixed-size header with the message length first, or use a delimiter (like a newline) to mark message boundaries. Read in a loop until you have assembled the complete message.

How do I scale beyond threading to thousands of connections?

For very high connection counts, the thread-per-connection model gets expensive (each thread uses ~8MB of stack memory). Switch to an asynchronous approach: use Python’s asyncio with asyncio.start_server() which can handle thousands of concurrent connections on a single thread using cooperative multitasking. Frameworks like Twisted and aiohttp are built on this model. For most practical applications (hundreds of connections), threading works fine.

What should I bind to — 127.0.0.1, 0.0.0.0, or the machine’s IP?

Bind to 127.0.0.1 (localhost) to accept only local connections — good for development and internal services. Bind to 0.0.0.0 to accept connections on all network interfaces, including from other machines on the network. Bind to a specific IP (like your machine’s LAN address) to listen only on that particular network interface. For security-sensitive services, always bind to the most restrictive address that meets your needs.

Conclusion

Python’s socket module exposes the full power of network communication at the byte level. You have covered the core TCP workflow (bind, listen, accept, connect, send, recv), multi-client handling with threads, UDP datagrams, timeout handling, and a practical concurrent port scanner. Understanding these fundamentals makes every higher-level networking library — requests, httpx, paramiko, database drivers — much less mysterious.

A natural next project is to extend the echo server with a simple chat protocol: add a client registry, broadcast incoming messages to all connected clients, and handle disconnection gracefully. That project will teach you message framing and concurrent state management — the two trickiest parts of real network server design.

For deeper study, see the socket module documentation and Beej’s Guide to Network Programming (the examples are in C but the concepts translate directly to Python’s socket API).