The first packet of everything
Type a URL and press enter. Before TLS, before HTTP, before a single byte of the page moves, your machine has to turn a name into an address. It does that with the oldest trick still running on the internet: it drops a tiny question into a UDP datagram, throws it at port 53, and hopes.
Most protocols make you earn the payoff: a handshake, a stream with no boundaries in it, framing rules invented to put them back, and pages of scaffolding before you can read a single exchange end to end. DNS needs one page, because the classic protocol is one datagram out and one datagram back. No connection, no framing, no handshake. The message you send is the packet, whole, and the original spec caps it at 512 bytes. You can hold the entire exchange in your head, byte by byte, and by the end of this page you'll have built one and launched it from your browser at a real resolver.
I got obsessed with this the honest way: by writing py-dns, a ~490 line Python server that binds port 2053, parses the real queries dig sends it, and answers them. Everything below is what the wire taught me, checked against RFC 1035 and against live packets. Every byte you'll see is from a real capture, and the figures tell you which day.
01 · One datagram each way
A DNS message has five parts, always in this order: a 12-byte header, then four lists of variable length. Questions, answers, authority records, additional records. That's the whole grammar, queries and responses share it. A query is just a message where only the question list has anything in it.
There's something quietly great about the transport too. Anything running over TCP has to invent its own boundaries — a length prefix, a delimiter, a blank line — because a byte stream has none to give. UDP hands them over for free. The datagram is the message, so DNS spends zero bytes on framing. The price is the famous limit: RFC 1035 §4.2.1 restricts UDP messages to 512 bytes, and anything longer gets cut and flagged. Modern clients lift the cap with EDNS0, which is why real dig traffic carries an extra OPT pseudo-record advertising a bigger buffer (1232 bytes is the fashionable number). We'll meet a packet that hit the 512 wall later in this post, live, with the flag set.
The query asking for example.com's address is 29 bytes. The answer is 61. Here's every field of both.
02 · Twelve bytes of header
The header is a fixed 12 bytes: a random ID, sixteen flag bits, and four counters saying how long each of the four lists is. All four examples below are real captured headers. Hover everything; the flags are where DNS keeps its personality.
A few things worth pulling out of those bits.
The ID is the only session state that exists. UDP doesn't know what a reply is, so the client invents a random 16-bit number and the server must echo it. When the answer comes back, matching ID means it's yours. That's the entire correlation machinery, two bytes of it.
The AD bit set on a query surprised me in the capture. That flag means "authentic data" and belongs to responses, where it reports that the resolver validated the answer with DNSSEC. But modern dig sets it on the way out too, which RFC 6840 §5.7 blesses as a way of saying: I understand this bit, set it honestly in your reply.
OPCODE 1 is a fossil. Inverse queries, "which name has this address", shipped in 1983, never worked well (a server could only invert the records it happened to hold), and were formally killed by RFC 3425 in 2002. py-dns answers any opcode other than 0 with RCODE 4, Not Implemented, which is exactly what the RFC tells a modern server to do.
And the counters are why parsing DNS is pleasant: the header tells you up front how many records of each section follow. No sentinels, no scanning ahead. py-dns's whole message parser is a loop per counter.
03 · Names without dots
Here's the part nobody expects: on the wire, example.com contains no dots. A name travels as a sequence of labels, each prefixed by its length, closed by a zero:
07 65 78 61 6d 70 6c 65 03 63 6f 6d 00
7 e x a m p l e 3 c o m root
The trailing 00 is a length too: the zero-length label, the nameless root of the whole tree. Every fully qualified name ends there, which is why example.com. with a trailing dot is the honest spelling and your browser's version is the abbreviation.
The limits come straight from the encoding. A label runs up to 63 bytes, a full encoded name up to 255 (RFC 1035 §2.3.4). And 63 isn't arbitrary: it's the biggest number that fits in six bits. The top two bits of every length byte are spoken for — the protocol's cleverest design decision.
04 · The two-bit pointer
A response repeats names constantly. The question says example.com, then every answer record starts with example.com again. In 1983, when 512 bytes was the entire budget, spelling that out each time was unacceptable. So RFC 1035 §4.1.4 added compression pointers: if the top two bits of a length byte are 11, it's not a length. It and the next byte form a pointer, and the low 14 bits say "the rest of this name lives at that byte offset, go read it there".
That's why labels stop at 63. Length bytes start with 00, pointers with 11, and 10 and 01 have been reserved for future use for over forty years now.
Walk it yourself through the real answer 1.1.1.1 gave me. Step 3 is the jump:
py-dns has to handle these because dig can't be talked out of understanding them, and real responses from upstream resolvers arrive compressed. Its parser is recursive: hit a pointer, recurse at the target, splice the labels. Fifteen lines that took me longer than any other fifteen in the repo.
05 · Questions, answers, and two ghost classes
After the names, the rest of the message is fixed-width fields. A question is a name plus two 16-bit numbers: QTYPE (what kind of record) and QCLASS (what universe to ask in). A resource record is a question with benefits: same name, type and class, then a 32-bit TTL, a 16-bit data length, and the data itself. For an A record the data is exactly four bytes, the IPv4 address raw.
The types you'll actually meet: A (1) for IPv4, AAAA (28) for IPv6, CNAME (5) for aliases, MX (15) for mail, TXT (16) for arbitrary strings and the whole cottage industry built on proving domain ownership through them, NS (2) for delegation. The full registry runs much longer, but that list covers most packets that will ever cross your machine.
CLASS is the better story. The designers imagined parallel namespaces: class 1 for the internet, class 3 for Chaosnet, MIT's 1970s LAN protocol, class 4 for Hesiod, MIT Project Athena's scheme for storing users and groups in DNS itself. The internet won so completely that every packet in this post says class 1. But the ghosts still answer. BIND repurposed the CHAOS class for server introspection, and I sent dig CH TXT version.bind to the big public resolvers. Quad9 answered "Q9-P-2026061300", class 3 on the wire in 2026. Google returned SERVFAIL, and Cloudflare a flat REFUSED. Three resolvers, three personalities, one undead class.
The TTL is the field that holds the internet together: how many seconds any cache may keep the record before it must ask again. You'll watch it do something great in a minute.
06 · Build one
Everything above, hands on. Type a name, pick a type, and read your own packet. The header is twelve bytes you now know, the ID is zero for a reason the next figure explains, and the name grows and shrinks label by label as you type:
07 · Send it for real
Those bytes are a valid DNS query, and your browser is about to prove it. It can't send UDP to port 53, but RFC 8484 defined DNS over HTTPS: POST the identical bytes to a resolver with content-type: application/dns-message, get the identical wire format back. Same packet, fancier envelope. That's also why the lab sets ID to zero: RFC 8484 asks clients to, so that HTTP caches see byte-identical queries.
The response is live. Send twice and watch the TTL drop as Cloudflare's cache ages between your clicks; wait past zero and it snaps back up. Those two answer records with their pointers back to byte 12 are the exact structures from the figures above, arriving from the actual internet.
08 · Who actually answers
One question remains: who did Cloudflare ask? Your machine's stub resolver knows nothing, it just asks a recursive resolver like 1.1.1.1. The recursive one walks the namespace tree: the root servers know who runs .com, the .com registry knows who runs example.com, and that final authoritative server holds the actual records. Delegation all the way down, and nobody knows the whole map.
Every message below is a real packet from walking that tree by hand, raw UDP sockets, no dig in the middle:
Two details in that capture are too good to skip. The root's referral arrived with TC=1: thirteen .com servers plus their IPv4 and IPv6 glue wouldn't fit in 512 bytes, so the root sent what fit and flagged the cut. That's the 1983 limit drawing blood daily, and it's why TCP port 53 and EDNS0 exist. And compare TTLs at the two ends: the authoritative server said 300, but 1.1.1.1 handed me 107, because its cache had been counting down for 193 seconds on someone else's behalf. The TTL you receive is almost never the TTL that was published; it's the remaining shelf life of a shared copy.
Where does py-dns sit in this diagram? Wherever you tell it to. Run it plain and it's a tiny authoritative server for its hardcoded zone. Run it with --resolver 1.1.1.1:53 and it becomes a forwarder: to your dig it looks like a resolver, but instead of walking the tree it repackages your question, asks the upstream, and relays the answer. That's the same slot your home router occupies. The forwarding loop is 20 lines of the main file, and there's no better way to feel the shape of the protocol than being the middleman.
09 · 512 bytes, forty years
That's DNS on the wire: a 12-byte header you can recite, names spelled as length-prefixed labels, a two-bit hack that compresses better than it has any right to, and a delegation walk that turns thirteen root server names into the whole internet's phone book. The 1983 design is still carrying almost every connection anyone opens, one small packet at a time.
If you want to poke it from the other side, clone py-dns, start it, and interrogate it with the same tool this post used on the real ones:
dig @127.0.0.1 -p 2053 example.com
Watch it answer, then read the ~490 lines that did it. Same punchline as the HTTP post: it's bytes all the way down, and you can read every one of them.