Python Sockets — UDP Client/Server

socket() + bind() + sendto/recvfrom. The simplest network programs that teach the model.

Building Block Foundational
7 min read
python sockets udp datagram programming

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 via ntplib, QUIC via aioquic. 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.DatagramProtocol is much nicer than blocking recvfrom in a thread.

How it works#

Minimal UDP server#

import socket
HOST = '0.0.0.0' # listen on every interface
PORT = 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:

  1. socket(AF_INET, SOCK_DGRAM) — request an IPv4 UDP socket from the kernel. AF_INET6 for v6; SOCK_DGRAM is what makes it UDP.
  2. bind((HOST, PORT)) — claim a local address. 0.0.0.0 means “every interface”; a specific IP restricts to one. Without bind, the OS picks an ephemeral source port on the first send.
  3. 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.
  4. sendto(data, addr) — push one datagram to addr. No retry, no ACK. If data is 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 socket
from 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 unless IPV6_V6ONLY is set.
  • Connected UDP. sock.connect(peer) filters incoming datagrams to that peer and enables send/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 to 255.255.255.255. LAN-local only; routers drop broadcasts.
  • Non-blocking / async. sock.setblocking(False) returns BlockingIOError instead of blocking. Use with selectors, select, or asyncio.DatagramProtocol.
  • Raw sockets. socket.SOCK_RAW lets you craft IP headers. Needs root. Used by ping, packet crafters, custom protocols below UDP/TCP.

Trade-offs#

Blocking sockets — synchronous, simple control flow, easy to reason about. Each call blocks until it completes (recvfrom waits forever without a timeout). Doesn’t scale to many concurrent peers without threads.
Non-blocking + 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 signals MSG_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 bind parameters or to run servers on different ports.
  • SO_RCVBUF tuning. The kernel drops datagrams when the receive queue overflows — silently. On high-throughput servers, raise SO_RCVBUF and watch netstat -su for packet 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 settimeout on the client. A blocking recvfrom with no peer reachable hangs forever. Always set a timeout.
  • Assuming small recvfrom buffers 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 sendto fragments at IP. Many firewalls drop fragments. Either chunk yourself or set IP_PMTUDISC_DO and probe path MTU.
  • Not closing sockets. gc will eventually close them, but in long-running scripts this leaks file descriptors. Use with socket.socket(...) as s: or explicit close().
  • Treating the source address as authentic. Sender IPs are forgeable. Don’t make security decisions based on recvfrom’s returned addr.
  • 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 bind is 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. Calling bind to port 0 is the explicit form.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.