Skip to content

Working with UDP sockets

The TCP echo server ended on a deliberate setup: TCP's socket API centers on a connectionconnect() on one side, accept() on the other, both sides then reading and writing to one dedicated socket for as long as that connection lives. UDP throws that entire model out. UDP's use cases already covered why an application would choose that trade-off — DNS queries, live media, multiplayer games, all cases where reconnecting per message would cost more than an occasional lost one. This article is about what changes in the code once that choice is made.

No connection means no connect(), listen(), or accept()

A UDP socket is created exactly the way the sockets article described, just with SOCK_DGRAM instead of SOCK_STREAM:

import socket

udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

That's already a meaningful difference from the TCP server, which needed four calls — socket(), bind(), listen(), accept() — before it could exchange a single byte. A UDP socket only ever needs bind(), and only on the side that needs to receive at a known, fixed address; there's no listen() because there's no backlog of pending connections to manage, and no accept() because there's no connection to accept in the first place. Every datagram this socket sends or receives carries its own destination or source address individually, rather than relying on an already-established connection to imply where it's going.

Sending and receiving: sendto() and recvfrom()

Because there's no connection binding a socket to one specific peer, every send has to name its destination explicitly, and every receive has to report where the data actually came from:

HOST = "127.0.0.1"
PORT = 65431

def main():
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server_sock.bind((HOST, PORT))
    print(f"Listening on {HOST}:{PORT}")

    while True:
        data, addr = server_sock.recvfrom(1024)
        print(f"Received {data!r} from {addr}")
        server_sock.sendto(data, addr)

if __name__ == "__main__":
    main()

recvfrom(1024) returns two things, not one: the bytes received, and the (ip, port) the datagram actually arrived from — addr here plays the exact role accept()'s returned address did for the TCP server, except it's supplied fresh on every single datagram instead of once per connection. sendto(data, addr) is the send-side mirror: every call has to specify a destination explicitly, because there's no established connection for the socket to remember it for. The while True: loop here isn't optional the way the TCP server's single-connection version was — since every datagram is independent, the server has to keep asking "what's next" indefinitely rather than following one connection through to its close.

The client side looks similar, sending to a fixed address without ever calling connect() first:

def main():
    client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

    for message in [b"first datagram", b"second datagram"]:
        client_sock.sendto(message, (HOST, PORT))
        data, server_addr = client_sock.recvfrom(1024)
        print(f"Sent:     {message!r}")
        print(f"Received: {data!r} from {server_addr}")

    client_sock.close()

Running the server and then the client produces:

Sent:     b'first datagram'
Received: b'first datagram' from ('127.0.0.1', 65431)
Sent:     b'second datagram'
Received: b'second datagram' from ('127.0.0.1', 65431)

Notice there's no equivalent of the TCP client's connection setup delay — no three-way handshake happens before the first sendto(), because there's nothing to negotiate. The datagram is simply put on the wire.

What "no connection" actually costs, visible directly in the code

Every guarantee TCP vs UDP described as TCP-specific shows up here as something the application now has to handle itself, if it needs it at all — the code above simply doesn't handle any of it, which is the point.

A datagram can arrive, get lost, or arrive out of order relative to others, and the socket API gives no signal either way. The TCP echo server's if not data: break relied on recv() returning empty bytes to signal the peer closing — UDP has no such signal, because there's no connection to close. If a client's datagram is dropped somewhere between the two hosts, the server's recvfrom() simply never returns for that message; nothing in the API reports the loss. An application that needs to know a message actually arrived has to build that confirmation itself — typically by having the receiver send back an acknowledgment datagram of its own, and having the sender resend after a timeout if no acknowledgment shows up in time.

A message boundary is guaranteed, in a way TCP's stream never promised. The TCP echo server article noted that recv() can return fewer bytes than the sender's single send() call transmitted, because TCP is a byte stream with no concept of "one send equals one receive." UDP is the opposite: one sendto() call produces exactly one datagram, and one recvfrom() call returns exactly one complete datagram — never a partial one, and never two merged together. That's a genuine, useful guarantee, but it comes with a ceiling: a datagram that's too large gets fragmented at the IP layer, or rejected outright depending on the platform and the size involved, which is exactly the fragmentation behavior the fragmentation article already covered — UDP doesn't get any special exemption from IP's own packet-size limits.

There's no automatic retry, no window, no congestion backoff. TCP's sliding window and congestion control exist to keep a fast sender from overwhelming a slow receiver or a congested network — none of that machinery exists for a UDP socket. A server calling sendto() in a tight loop can genuinely flood a receiver or the network path between them, with nothing in the transport layer stepping in to slow it down. Any pacing has to be the application's own responsibility.

Practice exercises

  1. Using the code above, explain why the UDP server's while True: loop is structurally necessary in a way the single-connection TCP server's version wasn't — what would happen to a second client's datagram if the loop only ran once?
  2. A UDP client sends a datagram and calls recvfrom() expecting a reply, but the server process isn't running at all. Using what this article said about UDP giving no delivery signal, predict what recvfrom() does in that case, and contrast it with what a TCP client's connect() would have done under the same circumstance.
  3. Sketch, in plain English rather than code, what an application would need to add on top of the UDP server above to guarantee a client eventually finds out whether its datagram was received, given that the raw socket API provides no such guarantee itself.

Both the TCP and UDP examples in this module handle exactly one client — or, for UDP, one datagram — at a time, with nothing else happening concurrently. A real server needs to talk to many clients simultaneously without one slow client blocking every other one's request, and that's a design decision that sits above the socket calls themselves, not inside them. The next article covers exactly that.

Sources