Python Sockets — UDP Client/Server
socket() + bind() + sendto/recvfrom. The simplest network programs that teach the model.
What it is#
The Python socket module is a thin wrapper over the BSD socket API in the C standard library — the same interface every Unix has exposed since the 1980s. Five calls — socket(), bind(), sendto(), recvfrom(), close() — are enough to write both ends of a UDP exchange in under twenty lines. There is no connect, no listen, no accept; UDP has no connection state to set up.
The socket API maps almost line-by-line to the protocol: you ask the kernel for a socket of type SOCK_DGRAM, you bind it to a local port if you want to receive, and then you push and pull datagrams. The address comes alongside every send and receive — the kernel doesn’t remember peers between calls. Writing a UDP echo server is the cleanest possible introduction to socket programming.
When to use it#
Use a raw UDP socket when:
- Teaching or learning networking. The lack of connection ceremony makes the protocol visible. Two terminals, two scripts, working network code.
- Implementing a UDP-native protocol. DNS clients, NTP probes, custom telemetry, game-server net code, service discovery (mDNS / SSDP).
- Multicast or broadcast. Joining a multicast group (
IP_ADD_MEMBERSHIP) or sending broadcast (SO_BROADCAST) requires a UDP socket. - Quick latency probes. Send a small datagram, time the reply. Avoids TCP handshake overhead in measurements.
Reach for higher-level libraries instead when:
- You need an application protocol. DNS via
dnspython, NTP viantplib, QUIC viaaioquic. Don’t reimplement what’s already a library. - You need reliability or order. Use TCP, or build on top of QUIC. UDP-from-scratch reliability is a textbook exercise but rarely production-grade.
- You want async.
asyncio.DatagramProtocolis much nicer than blockingrecvfromin a thread.
How it works#
Minimal UDP server#
import socket
HOST = '0.0.0.0' # listen on every interfacePORT = 9000
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)sock.bind((HOST, PORT))print(f'listening on udp/{PORT}')
while True: data, addr = sock.recvfrom(65535) # max IPv4 datagram size print(f'from {addr}: {data!r}') sock.sendto(b'ack: ' + data, addr)Walk-through:
socket(AF_INET, SOCK_DGRAM)— request an IPv4 UDP socket from the kernel.AF_INET6for v6;SOCK_DGRAMis what makes it UDP.bind((HOST, PORT))— claim a local address.0.0.0.0means “every interface”; a specific IP restricts to one. Withoutbind, the OS picks an ephemeral source port on the first send.recvfrom(buf_size)— block until a datagram arrives. Returns(payload, peer_addr). The buffer size must be at least the datagram length; oversized datagrams get truncated silently in some implementations.sendto(data, addr)— push one datagram toaddr. No retry, no ACK. Ifdatais bigger than the path MTU, IP fragments it (or drops it if the DF bit is set).
Minimal UDP client#
import socket
SERVER = ('127.0.0.1', 9000)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)sock.settimeout(2.0) # critical: blocking recv with no peer = forever
try: sock.sendto(b'hello', SERVER) reply, addr = sock.recvfrom(4096) print(f'got {reply!r} from {addr}')except socket.timeout: print('no reply within 2 seconds')finally: sock.close()Why settimeout: UDP has no FIN. If the server is down or the datagram is lost, recvfrom blocks forever. Always set a timeout on the client.
Framing — there is none, but you still need it#
Each sendto becomes exactly one datagram on the wire and exactly one recvfrom on the receiver. Datagram boundaries are preserved — unlike TCP, you don’t need length prefixes to separate messages.
But you do need:
- Size discipline. Keep payloads under the path MTU (target 1200 bytes for the open Internet, 1472 on local Ethernet). Larger datagrams fragment or get dropped.
- A serialisation format. JSON, msgpack, protobuf, or a custom binary layout. The kernel hands you raw bytes; the meaning is yours.
- Sequence numbers (if order matters). UDP can reorder. Prepend a 4-byte counter and reassemble on the receiver.
Retry pattern for request-reply#
import socket
def query(server, payload, timeout=1.0, retries=3): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(timeout) try: for attempt in range(retries): sock.sendto(payload, server) try: reply, _ = sock.recvfrom(65535) return reply except socket.timeout: continue raise TimeoutError(f'no reply after {retries} attempts') finally: sock.close()This is roughly what a DNS client does: send, wait, retry with backoff, give up. Notice that idempotency matters — a retried query that did arrive but whose reply was lost will execute twice on the server. UDP retries assume the server is safe to call repeatedly.
Receive loop with multiple peers#
import socketfrom collections import defaultdict
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)sock.bind(('0.0.0.0', 9000))
clients = defaultdict(int)while True: data, addr = sock.recvfrom(65535) clients[addr] += 1 print(f'{addr} count={clients[addr]} payload={data!r}')One socket serves all clients. The peer’s address comes back with every datagram. There is no accept because there is no connection.
Variants#
- IPv6 sockets.
socket.AF_INET6; addresses are(host, port, flowinfo, scopeid). Dual-stack sockets can accept both v4 and v6 unlessIPV6_V6ONLYis set. - Connected UDP.
sock.connect(peer)filters incoming datagrams to that peer and enablessend/recv. Useful for client code. - Multicast.
setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, group_struct)joins a multicast group. Sender sends to the group address; all joined receivers get a copy. - Broadcast.
setsockopt(SOL_SOCKET, SO_BROADCAST, 1)allows sending to255.255.255.255. LAN-local only; routers drop broadcasts. - Non-blocking / async.
sock.setblocking(False)returnsBlockingIOErrorinstead of blocking. Use withselectors,select, orasyncio.DatagramProtocol. - Raw sockets.
socket.SOCK_RAWlets you craft IP headers. Needs root. Used byping, packet crafters, custom protocols below UDP/TCP.
Trade-offs#
recvfrom waits forever without a timeout). Doesn’t scale to many concurrent peers without threads. selectors / asyncio — one thread handles thousands of peers; reads return immediately if no data is available. Steeper learning curve, more code, but the right model for any server doing more than toy work. Other trade-offs:
- Buffer size on
recvfrom. Too small truncates datagrams (Linux returns the partial payload and signalsMSG_TRUNC); too large wastes stack. 65535 is the safe maximum for IPv4. Most applications use 2048 or 4096. - Socket per peer vs single socket. One UDP socket can serve all peers (peer address comes with every datagram). Multiple sockets help only if you need different
bindparameters or to run servers on different ports. SO_RCVBUFtuning. The kernel drops datagrams when the receive queue overflows — silently. On high-throughput servers, raiseSO_RCVBUFand watchnetstat -suforpacket receive errors.- Source-address validation. UDP source IPs are easily spoofed. Authenticate at the application layer or use DTLS.
Why doesn't UDP need accept()?
TCP’s accept() returns a new socket for each connection because each connection is a distinct stream of bytes with its own state (sequence numbers, window, congestion). UDP has none of that — datagrams from many peers arrive at the same socket, and the peer address comes alongside every datagram in recvfrom. One socket, many peers, no per-peer state.
Common pitfalls#
- Forgetting
settimeouton the client. A blockingrecvfromwith no peer reachable hangs forever. Always set a timeout. - Assuming small
recvfrombuffers are safe. A 512-byte buffer truncates a 1400-byte DNS-over-UDP response and you’ll spend hours wondering why. Use 4096 or 65535. - Sending oversized datagrams. A 64 KB UDP
sendtofragments at IP. Many firewalls drop fragments. Either chunk yourself or setIP_PMTUDISC_DOand probe path MTU. - Not closing sockets.
gcwill eventually close them, but in long-running scripts this leaks file descriptors. Usewith socket.socket(...) as s:or explicitclose(). - Treating the source address as authentic. Sender IPs are forgeable. Don’t make security decisions based on
recvfrom’s returnedaddr. - Loopback assumptions.
'localhost'may resolve to v6 (::1) on some systems and v4 (127.0.0.1) on others. Bind explicitly to the family you want. - Mixing blocking and async on the same socket. Once you go async, stick with it. Mixing causes subtle deadlocks.
- Forgetting that
bindis optional on the client. A client that only sends and then reads the reply needn’t bind — the OS auto-assigns an ephemeral source port. Callingbindto port 0 is the explicit form.
Related building blocks#